🧩

Vue 3×Clean Architectureで破綻しないSPA設計 ─ Composable責務分離と設計ベストプラクティス【第2回】

に公開

📖 Vue 3 × Clean Architectureで破綻しないSPA設計 ─ Composable責務分離と設計ベストプラクティス【第2回】

📑 概要

中〜大規模Vueアプリ開発において破綻しがちなComposableの責務混在問題について、Clean Architectureの原則をベースに実践的な分割ルールと設計戦略を紹介します。再利用性・保守性・テスト容易性を高めるアプローチで解説します。

🎯 対象読者

  • Composition APIでの設計に迷い、Composableが肥大化しがちな方
  • Composableの設計方針を明確にし、チーム全体の開発効率を上げたい方
  • Vue + Clean Architectureの導入を検討している中〜大規模PJのエンジニア

🧭 前回のおさらいと今回のフォーカス

第1回では、Vue 3 + PiniaでClean Architectureに基づいたSPA設計の全体構造を解説しました。

✔ 前回の要点

  • Store(Pinia)をユースケース層として設計
  • API通信をGatewayに集約
  • UseCase(Store)経由でビジネスロジックを実行
  • View層との接続点としてInterface Adapter層(Composable) を導入

⛳️ 今回のフォーカス

Vue.jsで中〜大規模なSPAを開発していると、「Composableが気づけば肥大化して、何でもありになってしまう…」そんな悩みを抱えることはありませんか?
第2回となる今回は、まさにこのComposableの「何でもあり」状態に終止符を打ちます。

今回のテーマは、特に破綻しやすいComposableの責務整理と設計分離にフォーカスし、分類と実装例を提示します。
Clean Architectureの原則をベースに、Composableをどのように分類し、分割ルールを適用していくかを具体的に解説していきます。


⚠️ Composableの拡大と設計崩壊の兆候

🔍 Composableとは?

Vue 3のComposableは、Composition APIによって状態やロジックを関数化し、Viewに依存せず再利用可能にする仕組みです。これ自体は素晴らしい機能ですが、使い方を誤ると、大規模開発において深刻な問題を引き起こすことがあります。

📖 Vue アプリケーションの文脈で「コンポーザブル」とは、Composition API を活用して状態を持つロジックをカプセル化し再利用する関数です。

❗ Composable設計でよくある課題

  • UI状態とビジネスロジックの混在: 画面固有のUI表示ロジックと、アプリケーション共通のビジネスロジックが同じComposableに書かれ、カオス化する。
  • 画面単位で重複したAPI呼び出し: 複数の画面で同じAPIを呼び出しているのに、Composableが画面ごとに作られ、重複コードが増える。
  • Viewとユースケースの密結合: Viewから直接Store(ユースケース)を呼び出し密結合した結果、共通化が進まず特定のViewに依存したComposableが乱立する。
  • Composableの肥大化と再利用困難化: 一つのComposableに機能が詰め込まれすぎて、再利用どころか、そのComposable自体が理解困難になる。
  • テストや修正の影響範囲拡大: Composableの責務が不明確なため、小さな修正が予期せぬ箇所に影響を及ぼし、バグの温床となる。テストも複雑になりがちです。

🧱 Clean Architectureに基づくComposableの分類と責務

これらの課題を解決するには、Clean Architectureの根幹である「関心の分離(Separation of Concerns)」の原則をComposableにも厳格に適用することが効果的です。これにより、各Composableが持つべき責務が明確になり、秩序だった設計が可能になります。

📘 Composableの3分類

種類 Clean Architecture層 主な責務
UI Composable Interface Adapter層(Presenter / Controller相当) Viewの状態管理、表示制御、ユーザー入力の受け渡し
Application Composable Interface Adapter層(Presenter / Controller相当) UseCase(Pinia Store)実行制御、非同期フロー制御、画面遷移調整
Utility Composable(リアクティブユーティリティ) SPAにおける共通関心事(Cross-cutting Concern) リアクティブ性のある汎用ロジック(タイマー、ウィンドウ監視、イベント管理など)

Application ComposableはControllerとPresenterの両責務を担うが、複雑化した場合Presenter専用Composableを別途定義する運用も選択肢に入れる


📌 補足

  • UI ComposableとApplication ComposableはClean Architecture上のInterface Adapter層としての責務を分担。
  • Utility ComposableはVueのComposition APIの特性上、リアクティブ性を伴う共通ロジックとして存在価値があり、それはドメイン層やInfrastructure層には含めず、独立した役割で管理。
  • リアクティブ性のないユーティリティは関数モジュールに切り出し、Composableと明確に区別することで誤認を防止。純粋関数ユーティリティとして通常のTypeScriptユーティリティモジュール(utils/など)に整理する。
    例:文字列整形、日付計算、数値フォーマット、配列ソートなど。

📌 代表例まとめ

種類 代表例
UI Composable useFormState(), useModal(), usePagination(), useDialog()
Application Composable useFetchUser(), useCreateOrder(), useSubmitForm()
Utility Composable useIntervalTimer(), useWindowSize(), useEventListener()

✨ なぜ分離するのか? 〜メリット〜

Composableを明確に分離することで、以下のような大きなメリットが得られます。

  • 疎結合で変更に強い: UIの変更がビジネスロジック(UseCase層)に波及しないため、独立した修正が容易になります。
  • UseCase呼び出しの再利用性向上: どの画面からも同じApplication Composableを介してUseCaseを実行できるため、共通ロジックの再利用が促進されます。
  • テスト容易性の向上: 各Composableが単一の責務を持つため、テストコードがシンプルになり、単体テストが容易に行えます。特に非同期処理の状態管理もApplication Composableに集約されるため、テストが格段に楽になります。
  • Composableの肥大化防止: 責務が明確になることで、Composableが「何でも屋」になることを防ぎ、可読性と保守性を高めます。

📊 Composable種別ごとの役割比較

種類 担当すること 担当しないこと
UI Composable ref/v-model連携、表示制御、モーダルやフラグの状態管理 API通信、Store操作
Application Composable Store(UseCase)の実行、非同期処理、画面遷移・フロー制御 UI状態管理
Utility Composable タイマーなどの非UIかつStoreやエンティティに副作用のないリアクティブ処理 Store・APIへの依存を持つ処理、非リアクティブ系処理

🛠 UI Composableの実装例

// @/composables/ui/useModal.ts
import { ref } from 'vue'

export const useModal = () => {
  const isOpen = ref(false) // モーダルの表示状態
  const open = () => (isOpen.value = true)
  const close = () => (isOpen.value = false)
  return { isOpen, open, close }
}

🧭 運用ルール

  • API通信やビジネスロジックは含めない: 純粋にUIの状態と振る舞いのみを扱います。
  • シンプルで再利用性を意識: 特定のコンポーネントに強く依存せず、汎用的なUIパターンに適用できるようにします。
  • 状態 + UIイベントのみに責務を限定: refやreactiveでUIの状態を持ち、その状態を変更する関数を提供するのが主な役割です。

⚙ Application Composableの実装例

// @/composables/application/useFetchUser.ts
import { ref } from 'vue'
import { useUserStore } from '@/store/user'

export const useFetchUser = () => {
  const userStore = useUserStore()
  const isLoading = ref(false) // API呼び出し中のローディング状態

  const fetchUser = async (id: string) => {
    isLoading.value = true
    try {
      await userStore.fetchUser(id) // UseCase(Storeアクション)の実行
    } finally {
      isLoading.value = false
    }
  }

  return { fetchUser, isLoading }
}

🧭 運用ルール

  • 非同期制御、ユースケースの実行に専念: API呼び出しのローディング状態管理やエラーハンドリングなど、UseCaseの実行に関わるロジックを担います。
  • UI状態はUI Composableへ委譲: このComposableが直接UIの状態(例:モーダルの開閉)を持つことは避け、必要であればUI Composableを呼び出します。
  • Viewは必ずこのComposableを経由してUseCaseを実行: Viewから直接Storeを操作するのを避け、必ずApplication Composableを介することで、ユースケースの呼び出しロジックが一箇所に集約され、統一性とテスト容易性が向上します。

🔧 Utility Composableの例

Utility Composableは、リアクティブ性があるがUIやドメインに依存しない汎用的なロジックをカプセル化します。

// @/composables/utils/useIntervalTimer.ts
import { ref, onUnmounted, computed } from 'vue'

export const useIntervalTimer = (callback: () => void, interval: number) => {
  const timerId = ref<number | null>(null)

  const start = () => {
    if (timerId.value !== null) return
    timerId.value = setInterval(callback, interval)
  }

  const stop = () => {
    if (timerId.value !== null) {
      clearInterval(timerId.value)
      timerId.value = null
    }
  }

  onUnmounted(() => stop())

  return { start, stop, isActive: computed(() => timerId.value !== null) }
}

🧭 運用ルール

  • UIやユースケースに関するロジックは含めない: UIやユースケースに依存しないリアクティブ性のある状態と振る舞いのみを扱います。
  • ComponentやUI Composableから呼び出し可能: Application ComposableやStoreからの呼び出しは禁止します。これによりSPAの共通的関心事が内部のユースケースやエンティティを汚染することを防ぎます。
  • シンプルで再利用性を意識: 汎用的な利用シーンに適用できるようにします。
  • 他のモジュールを呼び出さない: 単独で汎用機能を提供するようにします。「依存性の方向性」が守られます。

🧩 Clean Architecture対応図(mermaid)

Composableの各分類がClean Architectureのどの層に位置し、どのように連携するかを図に整理します。

※外部接続系のGateway層、APIアクセス周りは図から省略しています。


🧠 テスト観点でのメリット

ComposableをUI ComposableApplication ComposableUtilityコンポーザブルに責務分離することで、以下のテスト上の利点が得られます。

📌 テスト容易性と責務分離の相乗効果

Composable分類 主なテスト対象 テスト容易性 理由・メリット
UI Composable UI状態の変化・表示制御 ★★★★★ 非同期・副作用なし、ref状態の変更確認のみ
Application Composable UseCaseの呼び出し制御、非同期制御 ★★★★☆ APIやStoreをMock化し呼び出し回数や引数確認
リアクティブユーティリティ リアクティブな値・監視対象の動作 ★★★★☆ 一般ユーティリティと同様+リアクティブ動作確認
純粋関数ユーティリティ 入力→出力の変換、計算結果の検証 ★★★★★ 副作用がないためテストが楽

📌 メリットまとめ

  • UseCase処理のMock化が容易

    • Application Composable経由に集約されるため、Store呼び出しを1箇所でMock化・監視可能
  • UIとロジックのテスト分離

    • UI Composableは表示制御のみ、Application ComposableはUseCase実行のみなので、責務単位のテストが明白
  • 非同期制御の分離とテスト易化

    • isLoading状態もApplication Composable内で終結し、Viewは状態受け取りのみ。非同期エラーやキャンセルの単体テストもやりやすい
  • 純粋関数のテストコスト大幅削減

    • 副作用ゼロのため入出力の検証のみでよく、網羅性・信頼性の高いテストが短時間で可能

🚦Pinia(UseCase層)とApplication Composableの責務の明確化

最後に「Pinia(UseCase層)が担うべき責務と、Application Composableが担うべき責務の具体的な線引きはどこにあるのか?」という点について整理しておきます。

結論から述べると、UseCase層はPinia Storeのactionとして「純粋なビジネスロジックの実行」に責任を持ち、その実行結果をPinia Storeのstateを通じてアプリケーション全体の状態として管理します。Application Composableは「Piniaアクションの呼び出しと、ビュー層が必要とするデータの加工・提供、およびビューの状態管理」に責任を持ちます。

より具体的には、以下の役割分担を推奨します。

🍍Pinia(UseCase層)が担うべき責務

  • 純粋なビジネスロジックの実装と実行
    データの取得、保存、計算、バリデーションなど、アプリケーションの中核となるビジネスルールに基づいた処理。これらはUIに依存しない、再利用可能なロジックとします。

  • アプリケーションの状態管理
    上記ビジネスロジックの実行結果として変更される、アプリケーション全体で共有される状態(例:ユーザー情報、商品リスト、フォームデータなど)の管理。Piniaのstateactionsgettersを活用します。

  • 外部サービスとの連携の抽象化
    API Gatewayなどを介した外部APIとの通信や、ローカルストレージへのアクセスなど、データソースとのやり取りの開始と結果の受け渡しを行います。具体的な通信方法はUseCase層からは意識せず、Gateway層に委譲します。
    ※Gatewayの実装と依存注入(DI)の方法については、第3回で詳しく解説予定です。

🧩Application Composableが担うべき責務

  • Piniaアクションの呼び出し
    ビュー(Vue Component)のイベント(ボタンクリック、フォーム送信など)ハンドラ内でコールされ、対応するPiniaのUseCaseアクションを呼び出します。

  • ビュー層へのデータ提供と加工
    Piniaのストアから取得した状態や、UseCaseの実行結果を、ビュー層が扱いやすい形に加工して提供します。例えば、表示用のフォーマット変換や、複数の状態を組み合わせてひとつのデータとして提供するなどが挙げられます。

  • 非同期処理のライフサイクル管理
    Piniaアクションの呼び出しに伴うローディング状態の管理やエラーハンドリングなど、一時的な状態は基本的にComposable内でローカルに管理します。アプリケーション全体で共有が必要な状態のみ、Piniaのstateで管理する方針とします。

  • 処理結果や状態に伴う画面遷移制御
    Piniaアクションの呼び出し結果や状態の内容に応じて、画面遷移を制御します。この制御も、Composableに切り出して集約しておくことで、ビュー層のコンポーネントをシンプルに保つことができます。

  • 複数のUseCaseのオーケストレーション
    ある特定のビューや機能において、複数のPiniaアクション(UseCase)を順次実行したり、その依存関係を管理したりする場合に、その流れをComposable内で制御します。
    例:ユーザー登録完了後に自動でプロフィール情報を取得し、取得完了後にダッシュボード画面へ遷移する、といった一連の処理をまとめる役割を担います。


このように明確な責任分担を設けることで、各層が独立性を保ちつつ連携し、コードの見通しが良くなり、テストもしやすくなります。次回の第3回では、この構成をベースに、UseCaseの実装と外部依存の注入方法(DI)の具体例を解説します。


✅ まとめ

今回の記事では、Vue 3におけるComposableの設計について、Clean Architectureの原則に基づいた実践的な分類と適用方法を解説しました。

  • ComposableはUI処理、ユースケース実行、ユーティリティ の3つに明確に分離する
  • Clean Architectureの「関心の分離」を適用することで、設計破綻を防ぎ、結果として開発効率と品質の向上に繋がる
  • 各Composableが単一の責務を持つことで、テスト性、再利用性、保守性が大幅に向上する

中〜大規模なVue.js開発において、このComposable設計が、複雑になりがちなプロジェクトを成功に導く鍵となるでしょう。


🔜 次回予告

📘 型安全なユースケース実行基盤の設計と依存方向の守り方

次回は、Pinia StoreをUseCase層として活用する際のより具体的な型定義、責務分離のベストプラクティス、そしてClean Architectureの肝となる「依存関係逆転の原則」に基づいた依存方向の守り方について詳しく解説します。


✉️ おわりに

本記事が「Composableの構造化設計」で悩むVue開発者の参考になれば幸いです。
ご質問・フィードバックは大歓迎です。次回もぜひご期待ください!

Discussion