フロントエンド開発を続けていると、必ずこの壁にぶつかる。コンポーネントの中に 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のエンドポイントがどこにあるか。

クリーンアーキテクチャのレイヤー構成図。Domain(中央)・Application・Infrastructure・UIの同心円構造を示すRough.js手描き風ダイアグラム


4層モデルの詳細解説

フロントエンドのクリーンアーキテクチャは4つの層で構成される。それぞれの役割、依存関係、TypeScriptでの表現方法を見ていこう。

Domain Layer(ドメイン層)——最も重要な核心

ドメイン層の定義: アプリケーションのビジネスルールそのもの。UIにもAPIにも依存しない、純粋なロジックの集合。

ドメイン層は、アプリケーションが「何をするものか」を表現する層だ。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 };
}

このユースケース関数も、CartRepositoryPaymentService の実装が何であるかを気にしない。テスト時にはモックオブジェクトを渡せばよく、本番では実際の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つに絞られる。

  1. ユーザーの操作をユースケースに転送する
  2. アプリケーション状態を画面に表示する

ビジネスロジックはここに書かない。APIの知識もここに持ち込まない。


依存の方向——このルールがすべてを決める

クリーンアーキテクチャの最重要ルールは「依存の方向は常に外から内へ」だ。

クリーンアーキテクチャの依存関係図。UI→Application→Domain の方向を青矢印、Domain→Applicationの禁止方向を赤×で示すRough.js図解

この図が示すように:

  • UIはApplicationを知っている(ユースケースを呼ぶ)
  • ApplicationはDomainを知っている(エンティティ・関数を使う)
  • InfrastructureはApplicationのPortを実装する(依存逆転の原則)
  • DomainはどこにもImportしない(完全に独立)

「依存逆転の原則(DIP)」がここで機能する。通常は「高レベルモジュール(Application)が低レベルモジュール(Infrastructure)に依存する」という逆転が起きてしまう。ポートを使うことで、依存の矢印を逆にする。Applicationは「こういう機能を持つ何か」というポートに依存し、Infrastructureがそのポートを実装する。これにより、矢印の向きは「Infrastructure → Application(ポート)」となり、ルールに従う。


Before / After で見るアーキテクチャの効果

クリーンアーキテクチャ適用のBefore/After比較図。左はコンポーネントがAPIを直接参照するスパゲッティ構造、右は層を分けた整理された依存関係を示す手描き風ダイアグラム

観点 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
レイヤー by レイヤー vs フィーチャー by フィーチャー
上の構造は「レイヤーで分ける」方式だ。一方「機能で分ける(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で見るデータフロー

クリーンアーキテクチャでユーザーが「チェックアウト」ボタンを押したときのデータフローを図式化する。

sequenceDiagram participant U as UI
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.cartRepositorydeps.paymentService として受け取った何かを呼んでいるだけで、それが LocalStorage なのか REST API なのかを判断しない。


Branded Types — ドメインの型安全性を高める

bespoyasov が元記事で触れた重要な改善点のひとつが、型エイリアスではなく Branded Types を使うことだ。

型エイリアスだと UserIdProductIdstring の別名に過ぎず、誤って混在させてもコンパイルエラーにならない。

// ❌ 型エイリアス — コンパイルエラーにならない
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. レイヤーを増やしすぎて複雑化する

過剰設計の罠: ドメイン層の中に「エンティティ層」「バリュートオブジェクト層」「ドメインサービス層」と細分化しすぎると、変更のたびに複数ファイルを触ることになる。始めはシンプルな4層構造で十分だ。

クリーンアーキテクチャが「合わない」ケースも理解する

bespoyasov 自身も元記事で正直に述べているが、クリーンアーキテクチャはすべてのフロントエンドに適しているわけではない。

日本のフロントエンドコミュニティでも「フロントエンドにクリーンアーキテクチャは合わない」という意見は根強い。その主な理由は以下だ。

  1. バンドルサイズの増大: インターフェース定義・ポート層が加わると初期ロード量が増える可能性がある
  2. オーバーエンジニアリングリスク: 単純なCRUDアプリに4層を強制すると開発速度が落ちる
  3. フロントエンドのロジックはバックエンドより薄い傾向: 多くの画面は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クライアントをインターフェース経由で呼ぶだけで、設計の方向性は変わり始める。

完璧なアーキテクチャより、変更しやすいアーキテクチャを目指す。そのための道具として、クリーンアーキテクチャは今日から使える。


参照ソース