フロントエンドにクリーンアーキテクチャは必要か?判断基準とWeb/モバイルの違い

開発

「フロントエンドにクリーンアーキテクチャは過剰だ」という意見をよく見かけます。確かにその通りだと思う場面も多いのですが、一方で「レイヤーを意識した設計」がまったく不要かというと、そうでもない。

実際に Next.js で Web アプリを作る場合と、Expo(React Native)でモバイルアプリを作る場合では、クリーンアーキテクチャの必要性がかなり違ってきます。この記事では、いつ採用すべきか/すべきでないかの判断基準を、実体験をもとに整理してみます。

クリーンアーキテクチャ採用の判断基準

結論から言うと、フロントエンドでクリーンアーキテクチャを「厳密に」適用すべきケースは限られています。海外の開発者コミュニティでも「It’s overkill for 90% of applications(90%のアプリケーションには過剰)」という意見が主流です。

ただし、レイヤーを意識した設計は別の話。以下のフローチャートで判断してみてください。

採用すべきケース

条件理由
ドメインロジックが複雑計算ルール、状態遷移、ビジネスルールが多い
長期運用(2年以上)フレームワーク変更の可能性がある
複数チームでの開発境界の明確化がコンフリクト削減に寄与
バックエンドとモデル共有ValueObjectやEntityの再利用が効く

避けるべきケース

条件理由
小規模プロジェクトオーバーヘッドがメリットを上回る
静的コンテンツ中心ドメインロジックがほぼない
プロトタイプ段階スピード優先、後でリファクタ
Server Componentsで完結Next.jsならサーバー側でロジック処理が自然

Web(Next.js)とモバイル(Expo)の違い

ここが重要なポイントです。Webとモバイルでは、クリーンアーキテクチャの必要性が異なります。

Webアプリ(Next.js)の場合

Next.js の Server Components や Server Actions を使うと、ロジックの大部分をサーバー側に置けます。この場合、クライアント側でわざわざレイヤーを作ってラップするのは過剰かもしれません。

// Server Actionで直接処理できる
'use server'
export async function createOrder(formData: FormData) {
  const result = await orderService.create(formData)
  if (!result.ok) {
    return { error: result.error }
  }
  revalidatePath('/orders')
  return { success: true }
}

サーバー側でバリデーション、エラーハンドリング、データ変換がすべて完結するなら、クライアント側に複雑なレイヤー構造は不要です。

モバイルアプリ(Expo/React Native)の場合

一方、モバイルアプリは事情が違います。

  • オフライン対応が必要(ローカルステート管理が複雑)
  • 複数のデータソースを扱う(API、ローカル DB、キャッシュ)
  • 状態管理がより複雑(ナビゲーション状態、認証状態など)
  • ビジネスロジックがクライアント側に集中しがち

こういった場合は、レイヤーを分けることで見通しが良くなります。

src/
├── domain/          # エンティティ、値オブジェクト
├── application/     # ユースケース
├── infrastructure/  # API、ストレージ
└── presentation/    # コンポーネント、hooks

データの流れを意識する

クリーンアーキテクチャを厳密に適用するかどうかより、データがどの方向に流れるかを意識することが重要です。

UI → Infra(書き込み/リクエスト)

ユーザーがボタンを押すなどの操作をすると、データは UI 層から内側に向かって流れます。

UI から Infra へのデータフロー

  1. UI Component → Contextual Component: UI イベントがコールバック関数をトリガーする
  2. Contextual Component → UseCase: ユーザー操作が Entity に変換される
  3. UseCase → Gateway: Entity がビジネスルール検証を受ける
  4. Gateway → Infra: Entity が API リクエストに変換される

Infra → UI(読み取り/レスポンス)

API からのレスポンスや初期データの取得では、データは外側から内側に向かって流れます。

Infra から UI へのデータフロー

  1. Infra → Gateway: API レスポンスを Entity に変換
  2. Gateway → UseCase: Entity を UseCase に渡す
  3. UseCase → Contextual Component: Entity を Contextual Component に渡す
  4. Contextual Component → UI Component: Entity を UI Component に渡す

この双方向のフローを意識することで、「どこでデータを変換するか」「どこでエラーを処理するか」が明確になります。完全なクリーンアーキテクチャを採用しなくても、この流れを意識するだけでコードの見通しは大きく改善します。

各層の責務:型の正規化とフォーマットの分離

レイヤー分離を採用する場合、各層で「何をすべきか」を明確にしておく必要があります。よくある間違いは、Repository層でフォーマットまでしてしまうこと。

各層の責務

責務やることやらないこと
Repository/API層型の正規化stringDate, centsnumberフォーマット
Domain層ビジネスロジック税込計算、ステータス判定表示形式の決定
Presentation層表示フォーマットDate3日前, number¥1,000データ取得

Repository層:型の正規化のみ

APIレスポンス(DTO)をドメインモデルに変換する際、型の正規化に留めます。

// ❌ フォーマットまでしてしまう(NG)
function toOrder(dto: OrderDTO): Order {
  return {
    total:${(dto.total_amount_cents / 100).toLocaleString()}`,  // 文字列化
    createdAt: formatRelativeTime(new Date(dto.created_at)),       // 「3日前」
  }
}

// ✅ 型の正規化のみ(OK)
function toOrder(dto: OrderDTO): Order {
  return {
    totalAmount: dto.total_amount_cents / 100,  // cents → 円(単位変換はOK)
    createdAt: new Date(dto.created_at),        // string → Date
    status: dto.status,                          // そのまま
  }
}

なぜフォーマットをRepository層でやってはいけないか

1. 多言語・ロケール対応ができなくなる

// Repository層でフォーマットすると...
{ formattedDate: '3日前' }  // 日本語固定、英語対応できない

// Date型で持ち回せば...
<p>{formatRelativeTime(order.createdAt, locale)}</p>  // ロケール切り替え可能

2. 表示コンテキストによって形式が変わる

// 同じDateでも画面によって表示が違う
<OrderList>     {format(order.createdAt, 'MM/dd')}</OrderList>           // 01/28
<OrderDetail>   {format(order.createdAt, 'yyyy年MM月dd日')}</OrderDetail> // 2026年01月28日
<OrderTimeline> {formatRelativeTime(order.createdAt)}</OrderTimeline>    // 3日前

3. テストが難しくなる

// ✅ プリミティブ型はテストしやすい
expect(order.totalAmount).toBe(1000)
expect(order.createdAt).toEqual(new Date('2026-01-28'))

// ❌ フォーマット済みはロケール依存でテストしにくい
expect(order.formattedTotal).toBe('¥1,000')  // CI環境でロケールが違うと失敗

Presentation層:フォーマットはUIの責務

// Presentation層でフォーマット
function OrderSummary({ order }: { order: Order }) {
  return (
    <div>
      <p>{formatCurrency(order.totalAmount)}</p>      {/* ¥1,000 */}
      <p>{formatRelativeTime(order.createdAt)}</p>    {/* 3日前 */}
      <p>{ORDER_STATUS_LABELS[order.status]}</p>      {/* 処理中 */}
    </div>
  )
}

まとめ:型とフォーマットの責務分離

変換の種類担当層
命名規則の変換Repositorytotal_amounttotalAmount
単位の変換Repositorycents
型の変換RepositorystringDate
表示フォーマットPresentationDate3日前
ラベル変換Presentationpending処理中

この責務分離により、Repository層は安定し、Presentation層は柔軟になります。

「完全なクリーンアーキテクチャ」より大切なこと

正直なところ、同心円の図を厳密に守る必要はないと思っています。大切なのは以下の 3 点です。

1. Result型と3層エラーハンドリング

フロントエンドのプロジェクトで雑なエラーハンドリングを見かけることが多いです。try-catch を散らばせるのではなく、Result型と3層の変換パイプラインで統一すると見通しが良くなります。

まず、Result 型を定義します。

// Result型の定義
interface Success<T> {
  readonly ok: true
  readonly value: T
}

interface Failure<E> {
  readonly ok: false
  readonly error: E
}

type Result<T, E> = Success<T> | Failure<E>

// ヘルパー関数
const success = <T>(value: T): Success<T> => ({ ok: true, value })
const failure = <E>(error: E): Failure<E> => ({ ok: false, error })

次に、エラーを 3 層で処理します。

// 1. BaseError型 - エラーの統一フォーマット
interface BaseError<TType extends string = string, TError = unknown> {
  type: TType
  message: string
  original?: TError  // 元のエラーを保持
}

type AppError = BaseError<'App', Error>
type NetworkError = BaseError<'network', ApiError> & { status: number }

// 2. transformError - unknown → BaseError に変換
const transformError = (error: unknown): BaseError => {
  if (ApiError.isApiError(error)) {
    return { type: 'network', original: error, status: error.status, message: error.message }
  }
  if (error instanceof Error) {
    return { type: 'App', original: error, message: error.message }
  }
  return { type: 'App', message: '不明なエラーが発生しました', original: error }
}

// 3. ErrorHandler - BaseError → 表示用ペイロードに変換
type ErrorDisplayPayload =
  | { displayType: 'toast'; title: string; message?: string }
  | { displayType: 'dialog'; title: string; message?: string; actions?: Record<string, (() => void) | undefined> }

type ErrorHandler<E extends BaseError> = (error: E) => ErrorDisplayPayload | undefined

// 実践的なErrorHandler - バックエンドのエラーコードで分岐
const createErrorHandler = ({ navigation }: { navigation: Navigation }): ErrorHandler<BaseError> => {
  return (error) => {
    // APIエラーの場合、エラーコードで分岐
    if (error.type === 'Api' && error.original) {
      const apiError = error.original

      // 認証エラー → ダイアログ + ログインボタン
      if (apiError.type === 'unauthorized') {
        return {
          displayType: 'dialog',
          title: '認証エラー',
          message: '認証が解除されました。再度ログインしてください。',
          actions: {
            キャンセル: undefined,
            ログイン: () => navigation.navigate('/sign-in'),
          },
        }
      }

      // バリデーションエラー → フィールドエラーがあれば表示しない(フォーム側で処理)
      if (apiError.type === 'validation_error' && apiError.fields?.length > 0) {
        return undefined  // ErrorDisplayServiceで表示しない
      }

      // 権限エラー
      if (apiError.type === 'forbidden') {
        return {
          displayType: 'dialog',
          title: '権限エラー',
          message: 'この操作を行う権限がありません。',
        }
      }
    }

    // デフォルト: Appエラーはtoast、それ以外はdialog
    return {
      displayType: error.type === 'App' ? 'toast' : 'dialog',
      title: error.type === 'App' ? 'エラー' : 'ネットワークエラー',
      message: error.message,
    }
  }
}

この 3 層構造のポイントは責務の分離です。transformErrorはエラーの正規化だけ、errorHandlerは表示形式の決定だけを担当します。最後に、これを使う Service を作ります。

// ErrorDisplayService - 実際の表示を担当
const useErrorDisplayService = () => {
  const toast = useToast()
  const dialog = useDialog()

  const show = useCallback(async ({ error, displayType = 'toast' }) => {
    const baseError = transformError(error)
    const payload = errorHandler(baseError)

    if (payload.displayType === 'toast') {
      toast({ variant: 'error', title: payload.title, description: payload.message })
    } else {
      await dialog.open({ title: payload.title, description: payload.message })
    }
  }, [toast, dialog])

  return { show }
}

この設計により、エラーの種類が増えてもtransformErrorerrorHandlerを拡張するだけで対応できます。

2. Branded TypeとUIモデルの分離

バックエンドと共通で使える ValueObject を定義しておくと、フロントエンド特有の「ID 間違い」「型の取り違え」を防げます。Zod を使った ValueObject の実装については Zodでバリデーションを一元管理する方法 で詳しく解説しています。

// Branded Type で型安全なIDを定義
declare const MessageIdBrand: unique symbol
export type MessageId = string & { readonly [MessageIdBrand]: typeof MessageIdBrand }

export const MessageId = {
  create: (id: string): MessageId => id as MessageId,
  generate: (): MessageId => crypto.randomUUID() as MessageId,
} as const

// 同様にUserId, ProjectIdなども定義
declare const UserIdBrand: unique symbol
export type UserId = string & { readonly [UserIdBrand]: typeof UserIdBrand }

これでコンパイル時に ID の取り違えを検出できます。MessageIdUserIdは異なる型として扱われるため、間違った引数を渡すとエラーになります。

さらに重要なのが、ドメインエンティティとUIモデルの分離です。

// ドメインエンティティ(APIから取得)
export interface Message {
  id: MessageId
  content: string
  authorId: UserId
  createdAt: string
}

// UI用のメタデータ
export interface UIMessageMetadata {
  id: MessageId
  isSending?: boolean           // 送信中フラグ
  pendingImages?: {             // 画像アップロード状態
    uri: string
    status: 'uploading' | 'completed' | 'failed'
    error?: string
  }[]
}

// UIモデル = Entity + Metadata
export type UIMessage = Message & UIMessageMetadata

export const UIMessage = {
  create: (message: Message, metadata?: Partial<UIMessageMetadata>): UIMessage => {
    return {
      ...message,
      isSending: metadata?.isSending ?? false,
      pendingImages: metadata?.pendingImages ?? [],
    } as UIMessage
  },
}

このUIMessageパターンのポイントは、ドメインエンティティ(Message)を直接拡張せず、UI 専用のメタデータを合成していること。これにより、ドメイン層の純粋性を保ちながら、UI 表示に必要な状態(送信中、エラー状態など)を持てます。

3. UseCaseパターンとコンポーネント分離

ビジネスロジックを UseCase として切り出し、コンポーネントから分離します。重要なのは、UseCaseファクトリはhooks非依存にし、呼び出し側のhooksでResult型とエラー表示を統合することです。

// libs/use-case/types.ts
type UseCase<TParams, TResult> = (params: TParams) => Promise<TResult>
type UseCaseFactory<TParams, TDeps, TResult> = (deps: TDeps) => UseCase<TParams, TResult>

// libs/use-case/index.ts - 純粋なファクトリ(hooks非依存)
const createUseCaseFactory = <TParams, TDeps, TResult>(
  useCaseFactory: UseCaseFactory<TParams, TDeps, TResult>,
  options?: { id?: string },
) => {
  return (deps: TDeps): UseCase<TParams, TResult> => {
    const useCase = useCaseFactory(deps)
    return async (params: TParams) => {
      // ここではtry-catchしない。呼び出し側に委ねる
      return await useCase(params)
    }
  }
}

UseCase 自体は純粋な関数として定義します。

// use-cases/send-message.ts
export const createSendMessageUseCase = createUseCaseFactory<
  { threadId: string; content: string },
  { api: MessageApi },
  Message
>(
  ({ api }) => async ({ threadId, content }) => {
    const messageId = MessageId.generate()
    return await api.sendMessage({ id: messageId, threadId, content })
  },
  { id: 'sendMessage' },
)

呼び出し側のhooksで Result 型への変換とエラー表示を統合します。

// hooks/use-use-case.ts
function useUseCase<TParams, TDeps, TResult>(
  createUseCase: UseCaseFactory<TParams, TDeps, TResult>,
  deps: TDeps,
  options?: { errorDisplayType?: 'toast' | 'dialog' | 'none' },
): [UseCase<TParams, Result<TResult, unknown>>, { isLoading: boolean }] {
  const [isLoading, setIsLoading] = useState(false)
  const errorDisplay = useErrorDisplayService()
  const useCase = createUseCase(deps)

  const command = useCallback(async (params: TParams) => {
    setIsLoading(true)
    try {
      const result = await useCase(params)
      return success(result)  // Result型で返す
    } catch (e) {
      // エラー表示を自動処理
      errorDisplay.show({
        error: e instanceof Error ? e : new Error(String(e)),
        defaultErrorDisplayType: options?.errorDisplayType,
      })
      return failure(e)  // Result型で返す
    } finally {
      setIsLoading(false)
    }
  }, [useCase, errorDisplay, options?.errorDisplayType])

  return [command, { isLoading }]
}

コンポーネント側はindex.ts(ロジック)とview.tsx(表示)を分けます。

// components/ChatInput/index.ts
export function useChatInput(threadId: string) {
  const [content, setContent] = useState('')
  const api = useMessageApi()

  // useUseCaseでResult型とエラー表示を統合
  const [sendMessage, { isLoading }] = useUseCase(
    createSendMessageUseCase,
    { api },
    { errorDisplayType: 'toast' },
  )

  const handleSend = useCallback(async () => {
    if (!content.trim()) return
    const result = await sendMessage({ threadId, content })
    if (result.ok) {
      setContent('')  // 成功時のみクリア
    }
    // エラー時は useUseCase が自動でtoast表示
  }, [content, threadId, sendMessage])

  return { content, setContent, handleSend, isLoading }
}
// components/ChatInput/view.tsx - 表示のみ
interface Props {
  content: string
  onChangeContent: (text: string) => void
  onSend: () => void
  isLoading: boolean
}

export function ChatInputView({ content, onChangeContent, onSend, isLoading }: Props) {
  return (
    <div>
      <input value={content} onChange={(e) => onChangeContent(e.target.value)} />
      <button onClick={onSend} disabled={isLoading}>送信</button>
    </div>
  )
}

この設計のポイントは:

  1. UseCaseファクトリはhooks非依存 - テストしやすく、再利用可能
  2. useUseCaseでResult型に統一 - 呼び出し側はresult.okで分岐するだけ
  3. エラー表示は自動処理 - 各コンポーネントで try-catch 不要

Feature Sliced Designという選択肢

海外では、クリーンアーキテクチャの代替として**Feature Sliced Design(FSD)**が注目されています。Feature Sliced Design のドキュメントでは「Clean Architecture answers ‘Which direction should dependencies flow?’ FSD answers ‘How do we structure a frontend repository?’」と説明されています。

FSD はフロントエンド向けに設計されたアーキテクチャで、以下の構造を採用します:

src/
  app/          # アプリ全体の初期化
  pages/        # ルートレベルのページ
  widgets/      # 複合UIコンポーネント
  features/     # ユーザー向け機能
  entities/     # ドメインエンティティ
  shared/       # 共有ライブラリ

クリーンアーキテクチャの原則(依存の方向性)を守りつつ、フロントエンドの実装に特化したディレクトリ構造を提供しています。

まとめ

フロントエンドにクリーンアーキテクチャが必要かどうかは、プロジェクトの複雑性とプラットフォームによって変わります。

プラットフォーム推奨アプローチ
Next.js(SSR/RSC中心)Server Actions活用、クライアント側は軽量に
SPA(CSR中心)レイヤー分離を検討、FSDも選択肢
モバイル(Expo/RN)レイヤー分離推奨、オフライン対応で複雑になりがち

「完全なクリーンアーキテクチャ」にこだわるより、以下を意識することをおすすめします:

  • Result型で成功/失敗を明示的に扱う
  • 3層エラーハンドリング(transformError → errorHandler → ErrorDisplayService)で責務を分離
  • Branded Typeで ID・値オブジェクトを型安全に
  • UIモデルでドメインエンティティと UI 状態を分離(UIMessageパターン)
  • UseCase Factoryはhooks非依存にし、useUseCaseで Result 型とエラー表示を統合
  • index.ts/view.tsx分離でロジックと UI を分ける

同心円の図を厳密に守ることより、チームが理解しやすく保守しやすい構造を選ぶことが大切です。

Thanks for reading!
clean-architecturereacttypescriptfrontendarchitecture