フロントエンド開発を続けていると、必ずこの壁にぶつかる。コンポーネントの中に fetch('/api/users') が直接書かれていて、テストを書こうにも外部APIが絡んで面倒になる。あるいはビジネスロジックがグローバルな状態管理と絡み合いすぎて、新機能を追加するたびに想定外の場所が壊れる。
問題の根本は「依存の方向が制御されていない」ことだ。コンポーネントがAPIを直接知っている。ストアがデータ変換を担っている。UIとビジネスロジックの境界が曖昧なまま成長し続けている。
クリーンアーキテクチャはこの問題への構造的な答えだ。バックエンド用の概念だと思われがちだが、Alex Bespoyasov が実証したように、React + TypeScript のフロントエンドでも完全に機能する。
AIコーディング × 設計パターン全体の解説はこちら:AIコーディング完全ガイド2026
- なぜ依存の方向が壊れるのか——スパゲッティ化のメカニズム
- Domain / Application / Infrastructure / UI の4層モデルの役割と境界
- ポートとアダプターパターン——Reactを「替えられる部品」にする仕組み
- TypeScript実装例(エンティティ / ユースケース / ポート)
- 段階的導入ガイド——既存プロジェクトへの適用戦略
- やってはいけない落とし穴5選と対処法
そもそも「クリーンアーキテクチャ」とは何か
クリーンアーキテクチャはRobert C. Martin(通称「Uncle Bob」)が2012年に体系化した設計原則だ。バックエンドの文脈で語られることが多いが、その本質は非常にシンプルだ。
「ビジネスルールを、UIやデータベースやフレームワークから独立させよ」
この一文がすべてを表している。UIが変わっても、APIが変わっても、フレームワークのバージョンが上がっても、ビジネスルールは変わらない。だから変わらないものと変わるものを分離し、変わるものは外側に追いやる。
フロントエンドに当てはめると、次のような分離になる。
- 変わらないもの: ユーザーがカートに商品を追加できる。注文は在庫がないと確定できない。
- 変わるもの: その処理をReactで表現するか、Vue で表現するか。APIのエンドポイントがどこにあるか。

4層モデルの詳細解説
フロントエンドのクリーンアーキテクチャは4つの層で構成される。それぞれの役割、依存関係、TypeScriptでの表現方法を見ていこう。
Domain Layer(ドメイン層)——最も重要な核心
ドメイン層は、アプリケーションが「何をするものか」を表現する層だ。import React from 'react' も import axios from 'axios' も絶対に書いてはいけない。この層に書けるのは、型定義・ビジネスルールを実装した純粋関数・ドメインイベントだけだ。
ECサイトを例にとると、「ユーザー」「商品」「カート」「注文」はドメインエンティティだ。「カートに商品を追加する」「注文合計を計算する」はドメイン関数だ。
// domain/cart.ts — Reactも外部ライブラリも一切使わない
export type Product = {
id: string;
name: string;
price: number;
inStock: boolean;
};
export type CartItem = {
product: Product;
quantity: number;
};
export type Cart = {
items: CartItem[];
};
// ビジネスルール: 在庫切れ商品は追加できない
export function addToCart(cart: Cart, product: Product): Cart {
if (!product.inStock) {
throw new Error(`${product.name} は在庫切れです`);
}
const existing = cart.items.find(i => i.product.id === product.id);
if (existing) {
return {
items: cart.items.map(i =>
i.product.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...cart.items, { product, quantity: 1 }] };
}
// ビジネスルール: 合計金額計算
export function calculateTotal(cart: Cart): number {
return cart.items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
}
このコードの最大の特徴は、どこでも動くことだ。ブラウザでも Node.js でも Deno でも。jest でテストするとき、モックは一切不要だ。純粋関数だから入力と出力だけを確認すればいい。
Application Layer(アプリケーション層)——ユースケースの指揮者
アプリケーション層は「ユーザーが何をするか」というシナリオを定義する層だ。ドメイン層の関数を組み合わせ、外部サービス(APIやストレージ)との連携を指揮する。
重要なのは、アプリケーション層は外部サービスの具体的な実装を知らないという点だ。代わりに「こういうことができる何か」というインターフェース(ポート)だけを宣言する。
// application/ports.ts — 「インターフェース」として宣言するだけ
export interface CartRepository {
save(cart: Cart): Promise<void>;
load(userId: string): Promise<Cart | null>;
}
export interface PaymentService {
charge(amount: number, userId: string): Promise<{ transactionId: string }>;
}
export interface NotificationService {
notify(userId: string, message: string): Promise<void>;
}
そして実際のユースケース:
// application/usecases/checkout.ts
import { addToCart, calculateTotal } from '../../domain/cart';
import type { CartRepository, PaymentService, NotificationService } from '../ports';
type Dependencies = {
cartRepository: CartRepository;
paymentService: PaymentService;
notificationService: NotificationService;
};
export async function checkoutUseCase(
userId: string,
deps: Dependencies
): Promise<{ success: boolean; transactionId?: string }> {
const cart = await deps.cartRepository.load(userId);
if (!cart || cart.items.length === 0) {
return { success: false };
}
const total = calculateTotal(cart);
const { transactionId } = await deps.paymentService.charge(total, userId);
// 成功後: 空カートで保存 & 通知
await deps.cartRepository.save({ items: [] });
await deps.notificationService.notify(userId, `ご注文確定: ¥${total.toLocaleString()}`);
return { success: true, transactionId };
}
このユースケース関数も、CartRepository や PaymentService の実装が何であるかを気にしない。テスト時にはモックオブジェクトを渡せばよく、本番では実際のAPIクライアントを渡す。
Infrastructure Layer(インフラストラクチャ層)——外の世界との架け橋
インフラストラクチャ層は、アプリケーション層が宣言したポートを実際に実装する層だ。ここでは外部APIを叩いたり、localStorage にデータを保存したりする。
「ポート」はインターフェース定義。「アダプター」はその実装。両者を分離することで、実装を差し替えられる。テスト時は
MockPaymentService、本番は StripePaymentService、という切り替えが型安全に行える。
インフラ層の実装例:
// infrastructure/adapters/localStorageCartRepository.ts
import type { CartRepository } from '../../application/ports';
import type { Cart } from '../../domain/cart';
export class LocalStorageCartRepository implements CartRepository {
private readonly key: string;
constructor(userId: string) {
this.key = `cart:${userId}`;
}
async save(cart: Cart): Promise<void> {
localStorage.setItem(this.key, JSON.stringify(cart));
}
async load(userId: string): Promise<Cart | null> {
const data = localStorage.getItem(this.key);
return data ? (JSON.parse(data) as Cart) : null;
}
}
このクラスは CartRepository インターフェースを実装しているため、アプリケーション層のユースケースに渡せる。将来 IndexedDB に切り替えたければ、IndexedDbCartRepository を作って渡すだけだ。アプリケーション層もドメイン層も変更不要。
UI Layer(UIレイヤー)——イベントの受付と表示
UIレイヤーは最も外側にある。React コンポーネント、状態管理、ルーティングがここに属する。UIレイヤーの役割は2つに絞られる。
- ユーザーの操作をユースケースに転送する
- アプリケーション状態を画面に表示する
ビジネスロジックはここに書かない。APIの知識もここに持ち込まない。
依存の方向——このルールがすべてを決める
クリーンアーキテクチャの最重要ルールは「依存の方向は常に外から内へ」だ。

この図が示すように:
- UIはApplicationを知っている(ユースケースを呼ぶ)
- ApplicationはDomainを知っている(エンティティ・関数を使う)
- InfrastructureはApplicationのPortを実装する(依存逆転の原則)
- DomainはどこにもImportしない(完全に独立)
「依存逆転の原則(DIP)」がここで機能する。通常は「高レベルモジュール(Application)が低レベルモジュール(Infrastructure)に依存する」という逆転が起きてしまう。ポートを使うことで、依存の矢印を逆にする。Applicationは「こういう機能を持つ何か」というポートに依存し、Infrastructureがそのポートを実装する。これにより、矢印の向きは「Infrastructure → Application(ポート)」となり、ルールに従う。
Before / After で見るアーキテクチャの効果

| 観点 | Before(スパゲッティ) | After(クリーンアーキテクチャ) |
|---|---|---|
| テスタビリティ | APIモックが必要。難しい | Domainは純粋関数。テスト容易 |
| API変更の影響 | 全コンポーネントに波及 | Adapterのみ変更で済む |
| フレームワーク変更 | 全面書き直し | UI層のみ変更。Domain/Appは無変更 |
| ビジネスロジックの場所 | コンポーネント・ストアに散在 | Domain層に一元化 |
| 新機能追加コスト | 高い(影響範囲が不明) | 低い(層の境界が明確) |
| オンボーディング | 把握が難しい | 層ごとに理解できる |
| バンドルサイズ | 影響なし | 若干増(インターフェース分) |
ディレクトリ構造の実践
理論を実際のディレクトリ構造に落とし込むと次のようになる。
src/
├── domain/ # ← フレームワーク・ライブラリ依存ゼロ
│ ├── cart.ts # エンティティ + ビジネス関数
│ ├── order.ts
│ └── user.ts
├── application/
│ ├── ports.ts # インターフェース定義(ポート)
│ └── usecases/
│ ├── checkout.ts
│ ├── addToCart.ts
│ └── removeFromCart.ts
├── infrastructure/
│ ├── adapters/
│ │ ├── restApiCartRepository.ts # 本番用
│ │ ├── localStorageCartRepository.ts # オフライン/テスト用
│ │ └── stripePaymentService.ts
│ └── http/
│ └── apiClient.ts
└── ui/ # React コンポーネント
├── components/
├── pages/
├── hooks/ # ユースケースをReactに橋渡し
└── di/ # 依存注入の設定
└── container.ts
上の構造は「レイヤーで分ける」方式だ。一方「機能で分ける(Feature-based)」方式は
src/features/cart/domain/, src/features/cart/application/ のようになる。どちらも有効で、プロジェクト規模によって使い分ける。大規模プロダクトではFeature-basedがチームの認知負荷を下げる傾向がある。
依存注入(DI)——アダプターを組み立てる場所
ユースケース関数はポートのインスタンスを外から受け取る。どこで組み立てるか?答えは「合成ルート(Composition Root)」だ。
// ui/di/container.ts — アダプターを組み立てる唯一の場所
import { LocalStorageCartRepository } from '../../infrastructure/adapters/localStorageCartRepository';
import { StripePaymentService } from '../../infrastructure/adapters/stripePaymentService';
import { BrowserNotificationService } from '../../infrastructure/adapters/browserNotificationService';
export const cartRepository = new LocalStorageCartRepository('user-123');
export const paymentService = new StripePaymentService(process.env.STRIPE_KEY!);
export const notificationService = new BrowserNotificationService();
React コンポーネントはこのコンテナからインスタンスを受け取るか、React Context / Zustand / Redux などのストアを通じて注入する。
Mermaidで見るデータフロー
クリーンアーキテクチャでユーザーが「チェックアウト」ボタンを押したときのデータフローを図式化する。
CheckoutButton participant UC as UseCase
checkoutUseCase participant D as Domain
calculateTotal participant CR as Adapter
CartRepository participant PS as Adapter
PaymentService U->>UC: checkoutUseCase(userId, deps) UC->>CR: load(userId) CR-->>UC: Cart UC->>D: calculateTotal(cart) D-->>UC: total: number UC->>PS: charge(total, userId) PS-->>UC: transactionId UC->>CR: save(emptyCart) UC-->>U: success: true
このフローで重要なのは、UCはCRやPSの実装を知らない点だ。deps.cartRepository や deps.paymentService として受け取った何かを呼んでいるだけで、それが LocalStorage なのか REST API なのかを判断しない。
Branded Types — ドメインの型安全性を高める
bespoyasov が元記事で触れた重要な改善点のひとつが、型エイリアスではなく Branded Types を使うことだ。
型エイリアスだと UserId と ProductId は string の別名に過ぎず、誤って混在させてもコンパイルエラーにならない。
// ❌ 型エイリアス — コンパイルエラーにならない
type UserId = string;
type ProductId = string;
function getProduct(id: ProductId) { /* ... */ }
const userId: UserId = 'u-001';
getProduct(userId); // TypeScriptはエラーにしない!
Branded Types を使えばこれを防げる:
// ✅ Branded Types — 型の混在をコンパイル時に検出
type Brand<T, B> = T & { readonly _brand: B };
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
function asUserId(id: string): UserId {
return id as UserId;
}
function getProduct(id: ProductId) { /* ... */ }
const userId = asUserId('u-001');
getProduct(userId); // ✅ コンパイルエラー! UserId != ProductId
これはドメイン層に入れる代表的なテクニックだ。プリミティブ型(string, number)をそのまま使うのではなく、意味のある型を作ることでバグを型レベルで防ぐ。
やってはいけない落とし穴5選
日本のフロントエンド開発現場でよく見られる、クリーンアーキテクチャの誤適用パターンを解説する。
1. ドメイン層に React の useState を使う
// ❌ NG: ドメイン層にReactが入り込む
import { useState } from 'react';
export function useCartDomain() {
const [cart, setCart] = useState<Cart>({ items: [] });
// ← これはUIフックであってドメイン層ではない
}
ドメイン層はフレームワーク非依存でなければならない。useState は UI層のカスタムフックで使い、ドメイン関数は純粋関数に保つ。
2. ポートなしで直接インポートする
// ❌ NG: UseCaseがaxiosを直接使う
import axios from 'axios';
export async function checkoutUseCase(userId: string) {
const cart = await axios.get(`/api/carts/${userId}`); // 直接依存
}
これではaxiosをfetchに変えるときにユースケース層を書き直す必要が生まれる。必ずポートを経由する。
3. エンティティにドメインロジックと関係ないものを混ぜる
// ❌ NG: UIのフォーマット処理がドメインエンティティに入り込む
export type Product = {
id: ProductId;
price: number;
formattedPrice: string; // ← これはUI関心事
imageUrl: string; // ← これはインフラ関心事
};
ドメインエンティティはビジネス上の意味のあるデータのみを持つ。表示用フォーマットやURLはアダプター・UI層で処理する。
4. ユースケースに try-catch を濫用する
ユースケースは「正常系のオーケストレーション」が役割だ。エラーハンドリングはUI層(ユーザーへの表示方法)とインフラ層(リトライ戦略)で分けて扱う。ユースケース内で try-catch してUIのエラーメッセージを直接組み立てるのは関心の混在だ。
5. レイヤーを増やしすぎて複雑化する
クリーンアーキテクチャが「合わない」ケースも理解する
bespoyasov 自身も元記事で正直に述べているが、クリーンアーキテクチャはすべてのフロントエンドに適しているわけではない。
日本のフロントエンドコミュニティでも「フロントエンドにクリーンアーキテクチャは合わない」という意見は根強い。その主な理由は以下だ。
- バンドルサイズの増大: インターフェース定義・ポート層が加わると初期ロード量が増える可能性がある
- オーバーエンジニアリングリスク: 単純なCRUDアプリに4層を強制すると開発速度が落ちる
- フロントエンドのロジックはバックエンドより薄い傾向: 多くの画面はAPI結果を表示するだけで、複雑なビジネスルールがない
これらの指摘は正しい。適用の判断基準は以下を目安にするとよい。
| 条件 | 推奨 |
|---|---|
| チーム人数 1〜2人、プロジェクト期間 < 6ヶ月 | 不要。コンポーネント + hooks で十分 |
| 複雑なビジネスロジックがUI側にある | 強く推奨。Domain層への分離が効く |
| バックエンドAPIが頻繁に変わる | 推奨。Adapterパターンで変更を局所化 |
| フレームワーク変更の可能性がある | 推奨。Domain/Appの資産を守れる |
| ユニットテストを重視するチーム | 強く推奨。純粋関数化でテストが容易に |
段階的な導入戦略——既存プロジェクトへの適用
ゼロから始めるプロジェクトは少ない。既存コードベースにクリーンアーキテクチャを持ち込む現実的なアプローチを示す。
ステップ1: 型定義を domain/ に移動(リスクゼロ)
既存コードにある TypeScript の型定義を src/domain/ フォルダに移動するだけ。動作変更なし、インポートパスの修正のみ。
ステップ2: APIクライアントをリポジトリパターンで包む
既存の fetch('/api/carts/...') 呼び出しを CartRepository インターフェースと RestApiCartRepository 実装に分離する。これだけで新規のユースケースからはPortを経由したアクセスが可能になる。
ステップ3: 新機能からユースケース関数を作る
既存機能は急いでリファクタリングしない。新規機能だけ をユースケース関数として実装する。同一コードベースにレガシーとクリーンアーキテクチャ部分が混在してよい。徐々に比率を上げる。
ステップ4: 複雑なコンポーネントからドメイン関数を抽出する
「カートの合計金額を計算するロジックがコンポーネント内にある」「注文確定のバリデーションがページコンポーネントに書かれている」——こういった箇所を Domain 層の純粋関数として切り出す。元のコンポーネントはその関数を呼ぶだけになる。
テスト戦略——クリーンアーキテクチャが真価を発揮する場所
クリーンアーキテクチャの最大の恩恵はテスタビリティだ。各層のテスト戦略を整理する。
Domain層: 純粋関数のユニットテスト。外部依存ゼロ。テストが最も速く書ける。
// domain/__tests__/cart.test.ts
import { addToCart, calculateTotal } from '../cart';
test('在庫切れ商品はカートに追加できない', () => {
const cart = { items: [] };
const product = { id: 'p1', name: 'テスト品', price: 1000, inStock: false };
expect(() => addToCart(cart, product)).toThrow('は在庫切れです');
});
test('合計金額が正しく計算される', () => {
const cart = {
items: [
{ product: { id: 'p1', name: 'A', price: 500, inStock: true }, quantity: 2 },
{ product: { id: 'p2', name: 'B', price: 300, inStock: true }, quantity: 1 },
],
};
expect(calculateTotal(cart)).toBe(1300);
});
Application層(ユースケース): ポートをモックオブジェクトで差し替えてテスト。APIは一切叩かない。
Infrastructure層: 統合テスト。実際のAPIや localStorage を使う。テスト数は少なめでよい。
UI層: コンポーネントテスト(React Testing Library など)。ユースケースをモックすれば UI ロジックだけをテストできる。
日本のフロントエンド事例から学ぶ
国内でもこのアーキテクチャの採用事例が出始めている。microCMS のブログでは View / Presenter / UseCase / Repository / Entity の5層構造を採用し、変更耐性の高いフロントエンド設計を公開している。弁護士ドットコムライブラリーでは Next.js + TypeScript で依存の方向を明示した設計を実践している。
共通して語られるのは「最初は面倒でも、半年後に変更コストが劇的に下がった」という体験だ。フロントエンドのビジネスロジックが増えるほど、この設計のリターンは大きくなる。
まとめ——どこから始めるか
フロントエンドのクリーンアーキテクチャの本質は、「依存の方向を制御すること」に尽きる。React でも Vue でも関係ない。フレームワークは外側の替えられる部品にし、ビジネスルールは内側の変わらない核心に置く。
実践の第一歩は小さくていい。まず型定義を domain/ ディレクトリに切り出し、APIクライアントをインターフェース経由で呼ぶだけで、設計の方向性は変わり始める。
完璧なアーキテクチャより、変更しやすいアーキテクチャを目指す。そのための道具として、クリーンアーキテクチャは今日から使える。
参照ソース
- Clean Architecture on Frontend — Alex Bespoyasov (dev.to) — 本記事の主要参考元。React + TypeScriptでのフルスタック実装例を含む2021年の代表的論考
- frontend-clean-architecture GitHub リポジトリ — bespoyasov — ECサイトのサンプルコード全体
- microCMSのWebフロントエンドにクリーンアーキテクチャを採用した話 — 日本での実採用事例
- クリーンアーキテクチャはなぜフロントエンドに合わないのか — panda-program — 反論側の論考。設計判断のバランスを考えるために参照