🌐

Vue 3×Clean Architectureで破綻しないSPA設計 ─ ApplicationStateの設計と責務分離の実践【第4回】

に公開

📖 Vue 3 × Clean Architectureで破綻しないSPA設計 ─ ApplicationStateの設計と責務分離の実践【第4回】

📑 概要

中〜大規模SPA開発で、肥大化する状態管理の設計と運用に頭を悩ませた経験はありませんか?
状態管理の設計はアーキテクチャの健全性を左右する最重要要素の一つです。特に認証情報、API制御状態、通知、アプリ初期化状態といった、特定のUseCaseに直接属さないグローバルな情報をどう扱うかは、Clean Architectureを実運用する上で多くの開発者が頭を悩ませるポイントです。

本記事では、このClean Architectureにおける「例外」とも言えるApplicationStateの設計と責務分離をテーマに、具体的な設計原則・運用ルール・実装例を徹底的に整理します。これにより、あなたのSPAが状態管理の肥大化や依存関係の崩壊に陥らず、長期的な運用を見据えた堅牢な状態管理設計を構築できるよう、具体的な指針を提示します。


🎯 対象読者

  • Vue 3 × Clean Architectureの設計・実装を実務で採用・運用しようとしている方
  • 認証情報・通知・グローバルなローディング状態など、「UseCaseに属さないアプリケーション横断状態」の管理方法に悩んでいる
  • 中〜大規模SPA開発で、状態管理の肥大化・依存関係の崩壊に課題を感じている方
  • Pinia × Composableを最大限に活用し、堅牢な状態管理と副作用の制御を両立させたい
  • 将来的な機能拡張・長期運用を見据えた状態管理の設計原則と運用ルールを整理したい
  • 第1〜3回を読んで、実践的な状態管理運用に興味を持った方

📌 今回の記事で得られること

  • ApplicationStateとDomainStateの責務の違いと明確な設計基準が分かります
  • ApplicationStateをClean Architecture運用下で安全に管理する目的別ストア分割とアクセスルールを学べます
  • グローバルな副作用を疎結合で扱うApplicationObserverパターンの導入方法を理解できます
  • 認証基盤の設計方針と、ストレージ保存・トークンリフレッシュ処理の実装戦略を習得できます

📌 なぜClean Architecture例外的なApplicationStateが必要なのか?

Clean Architectureの理想は「すべての状態もロジックもUseCaseに閉じ込める」ことです。確かに、これは素晴らしい目標です。しかし、 SPAの構造上、UseCaseのスコープに閉じ込めることが適さない「共有状態」 も確かに存在します。

なぜ「UseCaseに閉じ込めることが適さない」か?

それは、主に以下のような理由からです。

  • 複数のUseCaseから横断的に参照されるため
  • ビジネスロジックの範疇を超えた、アプリケーションのインフラ的な性質を持つため

これらの状態や制御情報は、厳密にはクリーンアーキテクチャにおけるFramework & Driver(インフラストラクチャ)層の責務に位置付けられるべきものに由来することが多いのです。

具体的には、以下のようなものが該当します。

  • 認証情報(アクセストークン、リフレッシュトークン、ログイン状態、ユーザー権限)
  • グローバルなローディング状態や、APIリクエストを制御するAbortControllerなどの制御情報
  • トースト通知、エラーメッセージなど、ユーザーに非同期に表示されるUIフィードバック
  • WebSocket接続状態、アプリケーションの初期化が完了したかを示すフラグ

このような状況に対応するためには、特定の画面やユースケースのスコープを超えて、アプリケーション全体で共有・管理される状態が必要になります。それが、本記事で焦点を当てる 「ApplicationState」(アプリケーション状態) です。

ApplicationStateとDomainStateの区別が重要

ここで最も重要なのは、すべての状態をApplicationStateとして「なんでもかんでもグローバル化」するわけではない、という点です。クリーンアーキテクチャの原則に基づくと、SPAには性質の異なる2種類の状態管理領域が存在します。

  • Domain State (ドメイン状態)

    • 特定のビジネスロジックやユースケースの実行に密接に関連する状態
    • 主にUseCase層(本シリーズではPinia Storeのstateactionsで実装)で管理されます。
    • 例としては、ユーザーがフォームに入力しているデータ、特定のAPIから取得したリストデータ、現在選択中のアイテムなどが挙げられます。
    • イメージとしては、そのUseCaseの実行が完了したり、特定の画面が閉じられたりすると、一旦忘れてよいデータが多いです。
  • ApplicationState (アプリケーション状態)

    • アプリケーション全体に影響を及ぼし、特定のユースケースのスコープを超えて永続的、またはグローバルに参照される状態
    • アプリケーションの様々な部分からアクセスされ、時にはアプリケーション全体の動作に副作用を伴うグローバルな振る舞いをトリガーすることもあります。
    • 例としては、ユーザーの認証情報(ログイン状態、トークン)、アプリケーション全体のテーマ設定グローバルな通知メッセージAPIのロード状況エラーメッセージなどが挙げられます。
    • これらの状態は、アプリケーションのライフサイクル全体にわたって存在し、多くのUseCaseやUIコンポーネントから参照される可能性があります。

それぞれの責務に応じて適切な場所で管理することが、アプリケーションの凝集度を高め、疎結合を維持し、テスト容易性を向上させる鍵となります。さらに、長期運用時の保守性や機能追加時の拡張性を確保するうえでも不可欠な設計判断となることを覚えておいて下さい。

※「ApplicationState」という名称は、SPA開発コミュニティやClean Architectureの文脈で一般的に標準化された用語ではありません。本記事ではDomain Stateとの対比を明確にするための便宜的な呼称として採用しています。議論上は、AppStateSystemStateUIState など、類似する概念を指す名称が挙げられることもあります。
プロジェクト運用にあたっては、より意味を明示する目的で AppSharedStateSystemStateGlobalAppState など、プロジェクトの文脈に適した名称を選定するのも良いでしょう。

ApplicationStateとDomain Stateの比較表

以下の表は、ApplicationStateとDomainStateの違いを責務・スコープ・ライフサイクルの観点で整理したものです。

特徴 ApplicationState Domain State
管理主体 専用のApplicationState用Pinia Store (例: AuthStore, NotificationStore) UseCase用Pinia Store (例: UserManagementUseCaseStore) 一時的かつUI的な情報はApplicationComposableが保有する
スコープ アプリケーション全体、グローバル 特定のビジネスロジック、UseCase、画面のスコープに限定
ライフサイクル アプリケーション起動から終了まで永続的、またはグローバルに参照される UseCaseの実行期間中、または特定の画面表示期間中に限定されることが多い
責務 アプリケーションのグローバルな状態、設定、システムレベルの情報を管理 特定のビジネス要件、データ取得、フォーム入力など、ユースケースに特化した情報を管理
具体例 ユーザー認証情報(ログイン状態、トークン、権限) ユーザーリストのデータ、フォームの入力値、検索結果のフィルター条件
アプリケーションのテーマ/言語設定 選択中の商品詳細データ、カート内のアイテム一時情報
グローバル通知メッセージ(トースト、アラート) ユーザープロフィール編集フォームの各フィールド値
グローバルなロード/エラー状態、API制御(AbortControllerなど) (例:特定のテーブルの)ページネーション情報
変更のトリガー ログイン/ログアウト、テーマ変更、グローバルなイベント、システムエラー(予期せぬもの) ユーザーのアクション(ボタンクリック、入力)、APIからの応答

ApplicationStateをビジネスロジックが責務であるUseCase内で扱ってしまうと、UseCaseストアが本来扱うべきではない責務で肥大化し、依存関係が崩壊します。そこで、Clean Architectureの例外としてApplicationState領域を設け、厳格な設計ルールのもとで責務を分離し、依存関係を制御する「準レイヤー」として運用することで、アーキテクチャの健全性を保つ必要があるのです。


📌 ApplicationStateの設計原則とルール

ApplicationStateは「例外」だからといって、好き勝手に扱うのはNGです!例外領域内でもしっかりと制約と設計方針を設けることが、破綻しない設計へ繋がります。

✅ 設計原則

  • 単なるグローバル変数化を避け、責務分離とアクセス制御を徹底するApplicationStateは「何でも置けるグローバルなゴミ箱」ではありません。特定の目的を持たせ、明確なルールで管理しましょう。
  • Clean Architectureの例外であっても、例外領域内で制約と設計方針を設ける:例外を認めるからこそ、その例外が制御不能にならないように、自分たちでルールを定義し、厳格に運用することが重要です。

✅ 管理ルール

📌 Storeの目的別分割:ApplicationStateも安易な集約はアンチパターン!

ApplicationStateといえども、すべてを一つのストア(例: useGlobalStore)に集約するのは典型的なアンチパターンです。単一の巨大なストアはすぐに肥大化し、責務が曖昧になり、結果的に変更の影響範囲が広がりやすくなります。

「なぜそのストアが存在するのか」という目的を明確にし、その目的に応じてPiniaストアを分割することで、責務ごとに疎結合化を図りましょう。

分割例:こんな風に分割するとGOOD!

Store名 管理する状態 主な責務
useAuthStore トークン、認証状態、ユーザー権限情報 認証状態の管理、トークンリフレッシュ処理、ログイン/ログアウト処理
useAppStateStore アプリ初期化状態、テーマ設定、言語設定、グローバルローディング アプリ全体の汎用状態管理、起動時初期化処理
useNotificationStore トースト、スナックバーメッセージ UI通知の集中管理、メッセージキューの管理
useServiceControlStore AbortControllerなどAPI制御情報 Gateway層-View層間で連携する情報の管理、APIキャンセルの責務

特にAbortControllerのような「API Gatewayに責務があるが、Presenter(Application Composableなど)が実行を制御する」情報は、useServiceControlStoreのように独立したストアで管理することで、責務のねじれを防ぎ、よりクリーンな依存関係を保てます。


📌 Composable経由のアクセス統一:ApplicationStateの玄関口を定める

ApplicationStateへのアクセスも、必ずComposable経由に統一します。これは、第2回で解説したComposableの責務分離の原則を、ApplicationStateにも適用するということです。

💡 なぜComposable経由に統一するか?

  • アクセスの一元化と可視化: どこからでも同じComposableを使って状態にアクセスするため、コードの一貫性が保たれ、状態のフローが追いやすくなります。
  • ロジックの再利用と腐敗防止: 単純な状態の読み取りだけでなく、その状態から派生する計算ロジック(例: ユーザーが管理者かどうかisAdmin)、複数のストアを組み合わせた複合的なロジックをComposable内にカプセル化し、ロジックの重複(コードの腐敗)を防ぎます。
  • 将来的な変更への耐性強化: 将来的にApplicationStateの内部構造や更新方法が変更された場合でも、直接ストアに依存している箇所はComposable内に限定できるため、修正範囲を局所化できます。これにより、状態依存箇所の集中管理と変更耐性を確保できます。
  • ユニットテストの容易化: Composableをモックすることで、ストアに依存するコンポーネントのテストを容易に行えます。
    例:Vue Test UtilsでのComposableのモック化が容易

📦 Composable実装例:useAuth Composable

useAuthStoreへのアクセスをラップするuseAuth Composableの例を見てみましょう。

// src/composables/useAuth.ts
import { computed } from 'vue';
import { useAuthStore } from '@/stores/authStore'; // AuthStoreをインポート

/**
 * 認証状態に関するApplication Composable
 * AuthStoreへのアクセスをカプセル化し、UI層に認証関連情報を提供する
 */
export function useAuth() {
  const store = useAuthStore(); // Piniaストアのインスタンスを取得

  // ログイン状態を示すリアクティブな計算プロパティ
  const isLoggedIn = computed(() => store.isAuthenticated);
  // 現在のユーザー情報を取得する計算プロパティ
  const user = computed(() => store.getUser); // StoreのGetterを介してアクセス

  // ログイン処理をカプセル化する関数
  const login = (token: string, user: any) => {
    // ストアのActionを呼び出して状態を変更
    store.setAuthInfo({ accessToken: token, refreshToken: 'dummy_refresh_token', user });
  };

  const logout = () => {
    // ストアのActionを呼び出して状態を変更
    store.clearAuthInfo();
  };

  // UI層に提供する公開インターフェース
  return { isLoggedIn, user, login, logout };
}

📌 Getter/ActionによるRead/Write分離:ApplicationStateは明確な操作で!

ApplicationStateは、双方向リアクティブ性のあるViewModelデータではなく、明確なトリガーでアプリケーション自体が制御する情報となるため、以下の運用ルールを徹底します。

操作 方法 理由
読み取り Getter経由 状態の意図しない変更を防ぎ、読み取りの意図を明確化します。リアクティブな更新はPiniaが担保します。
書き込み Action経由 状態変更箇所をストアのactionsに集中させ、変更の追跡と管理を容易にします。これにより、意図しない副作用を防ぎ、デバッグ効率を高めます。

特にトークン更新のような機密性の高い処理や、AbortControllerの操作などグローバルな副作用を伴う処理は、必ずAction経由で実行し、状態変化の副作用と影響範囲を明確化します。


📌 認証基盤の設計ポイント:AuthStoreをハブに

ApplicationStateの中でも特に重要なのが認証情報です。AuthStoreは、認証状態、トークン、ユーザープロファイルなどの単なる「入れ物」ではなく、これらを一元的に管理する、アプリケーションにおけるセキュリティのハブとなります。

設計の例として以下のポイントを挙げます。

  • トークンとユーザー情報の保持:
    アクセストークン、リフレッシュトークン、ユーザーの基本情報をストアのstateとして持ちます。これらの情報は、isAuthenticatedのようなGetterで導出される認証状態の元となります。
  • リフレッシュ処理Actionの集中化:
    トークンリフレッシュのような複雑な非同期処理は、AuthStore内のActionにカプセル化します。このActionは、次回扱う予定のGateway層と連携し、API通信を行います。AuthStoreが認証に関するビジネスロジックの実行責任を持つことで、関連ロジックが分散せず、堅牢な認証フローを構築できます。
  • ストレージ保存方針の決定と実装:
    AuthStoreAction内で、トークンなどの機密情報をどこに保存するか(LocalStorage, SessionStorage, Cookieなど)を設計時に決定し、その保存・読み込みロジックを記述します。セキュリティ要件に応じて最適なストレージを選択しましょう。
  • 認証状態のアプリ起動時初期化:
    アプリケーション起動時に、ストレージから認証情報をロードし、AuthStoreAction内で初期化するロジックを記述します。これにより、ページリロード後もユーザーの認証状態が維持されます。

📌 拡張案:ApplicationObserverの導入で、グローバルな副作用を疎結合に!

Vueアプリケーション内のレイヤー間は、リアクティブシステムによりComposableやStore経由で最新の状態を参照し、UIを更新することができます。値の参照と状態同期のみであれば、リアクティブな変数の読み取りで十分です。

しかし、Vueのリアクティブシステムではコンポーネント内のUI更新には自動で対応できますが、外部サービスの接続・切断やブラウザの機能との連携など、Vueの監視外の副作用処理には別の仕組みが必要です。そうしたときに役立つのがObserverパターンです。

これをここではApplicationObserver層と呼びます。(💡本記事では便宜上ApplicationObserverと記述しますが、用途に応じてStateObserverStateEventHandlerStateEffectHandlerStateSideEffectManagerなど、より適切な名称で設計してください。)

例:こんな時にApplicationObserverを活用できます

  • 認証状態変化時: 認証状態の変化(ログイン/ログアウト)に応じてWebSocketクライアントを再接続したり、特定のサービスを初期化・破棄したりする場合(外部通信系との連携)
  • ユーザー設定変更時: ユーザー設定(例: テーマ)が変更された際に、それをローカルストレージに永続化したり、CSS変数を動的に変更したりする場合(ストレージ同期やDOM操作)
  • 複数タブ間通信: BroadcastChannelを介して、子ウインドウや複数タブ間でアプリケーションの状態を伝搬させたい場合(多ウインドウ・タブ間通知)

これにより、状態の管理(Store)と、その状態変化に伴う副作用の発生を疎結合化し、拡張性と保守性を高めることができます

Observerパターンの簡易コード例

AuthStoreの認証状態を監視し、それに応じてWebSocket接続を制御するAuthObserverの簡易例です。

// src/observers/AuthObserver.ts (簡略化された例)
import { watch } from 'vue';
import { useAuthStore } from '@/stores/authStore';
// import { webSocketService } from '@/infra/websocketService'; // 仮のWebSocketサービス。Infrastructure層に属する

/**
 * 認証状態の変化を監視し、外部(Vueリアクティブシステム外)の副作用を制御するObserver
 * Vueのリアクティブシステムを介さない、グローバルなイベントや外部サービスとの連携を担う
 */
export class AuthObserver {
  constructor() {
    const authStore = useAuthStore();

    // 認証状態の変化をリアクティブに監視
    watch(() => authStore.isAuthenticated, (newVal) => {
      if (newVal) {
        console.log('認証済みになりました。WebSocketを再接続します。');
        // webSocketService.connect(); // 💡 実際のWebSocket接続処理を呼び出す
      } else {
        console.log('未認証になりました。WebSocketを切断します。');
        // webSocketService.disconnect(); // 💡 実際のWebSocket切断処理を呼び出す
      }
    }, {
      // initial: true を設定すると、インスタンス生成時にも一度コールバックが実行される
      immediate: true
    });
  }

  // アプリケーション起動時にObserverを初期化するメソッドなど
  public initialize() {
    console.log('AuthObserver initialized.');
  }
}

// src/main.ts などでインスタンス化して起動
// const authObserver = new AuthObserver();
// authObserver.initialize(); // アプリケーションの初期化ロジック内で呼び出す

💡ここではVueのリアクティブ監視watchを用いていますが、本質は「ApplicationStateの状態変化を検知し、Vueコンポーネントの再描画とは独立した副作用を実行する責務の切り出し」です。Observerパターンそのものを実装する場合、独自のPub/Sub実装やEventEmitterでも代替可能です。

AuthObserverは、useAuthStoreの状態を参照していますが、AuthStoreAuthObserverの存在を知りません。これにより、StoreとObserverが疎結合になり、どちらか一方を変更してももう一方に影響が出にくくなります。


📌 まとめ

中〜大規模SPAにおいて、Clean Architectureの原則を維持しつつ、グローバルな「ApplicationState」を効果的に管理するための設計指針を解説しました。

  • ApplicationStateの役割を明確にし、設計原則を明文化する:グローバル状態を単なるClean Architectureの例外とせず、システム運用に不可欠なものとして捉え、管理方針を定めます。
    DomainStateとの厳密な区別が健全性維持の鍵となります。
  • 堅牢なApplicationState管理を実現する3つの設計原則
    1. Storeの目的別分割:巨大な「グローバルストア」ではなく、責務ごとに分割します。
    2. Composable経由のアクセス統一:状態の読み書きの窓口を一本化し、可視性と保守性を向上させます。
    3. Getter/Actionによる読み書き分離:意図しない状態変更を防ぎ、副作用の追跡を容易にします。
  • ApplicationObserverで複雑な副作用を疎結合に管理する:Vueのリアクティブシステムの範囲外で発生するアプリケーション全体の副作用をObserverパターンで切り出し、拡張性と保守性を高めます。認証状態に応じたWebSocket接続制御などがその典型例です。

これらを実践することで、Clean Architectureベースでも破綻しない状態管理設計を構築し、長期的なプロジェクトの成功に大きく貢献できるはずです。


📖 次回予告

第5回は、 本記事で整理したApplicationState運用ルールを前提に、 型安全API基盤の実装、型安全なAPI設計、堅牢なエラーハンドリング、そしてAPIのキャンセル機構などの設計実装を解説します。

Clean Architectureの「Infrastructure」層の具体的な実装に深く切り込みますので、どうぞお楽しみに!


✉️ おわりに

今回の記事に関して、ご質問やフィードバックがありましたら、Zennのコメント欄などでぜひお気軽にお寄せください。皆さんのVue開発に少しでも役立てれば幸いです。


Discussion