📐

DIコンテナでフロントエンドの依存性を解決する(Nuxt.js)

に公開

はじめに

フロントエンドでも、依存性注入(DI)を活用できます。
バックエンドと異なり、フロントエンドは「1アクション / 1トランザクション」ではなく、ユーザー操作(クリック、入力など)に応じて、任意のタイミングでアクションが発生します。

複数のコンポーネントから同じ機能にアクセスする必要があるため、クラスや関数のパラメータによる依存性注入だけでなく、DIコンテナでグローバルに管理し、どこからでも呼び出せるようにする方が適していると考えます。

DIコンテナ(本プロジェクトでは InversifyJS を使用)により、依存関係を宣言的に管理し、柔軟な解決を実現できます。

本記事では、features/{機能} 構成のプロジェクトで、コンテナコンポーネントや部分コンポーネントからもDIコンテナを利用できる仕組みを説明します。

プロジェクト構成

本プロジェクトは、機能ごとに features/{機能} ディレクトリで構成されています。
(この構成は、「 Feature-Based Directory Structure (フィーチャーベースのディレクトリ構成)」や、「 Package by Feature 」として知られています)

実際のプロジェクトでは、

  • features/user (ユーザー管理)
  • features/product (商品管理)
  • features/auth (認証)
  • features/order (注文管理)

といった、機能ごとにディレクトリが分かれています。

features/{機能}/
  ├── di/                    # DIコンテナの設定
  │   ├── symbols.ts         # 依存関係のシンボル定義
  │   └── {機能}.inversify.config.ts  # バインド定義
  ├── application/           # アプリケーション層
  ├── infra/                 # インフラ層
  ├── presentation/          # プレゼンテーション層
  ├── gateways/              # ゲートウェイ層
  ├── containers/            # コンテナコンポーネント
  ├── components/            # 部分コンポーネント
  └── ...

この構成により、各機能を独立して管理し、DIコンテナで依存関係を解決できます。

DIコンテナのグローバルアクセス

DIコンテナは、Nuxtプラグインでアプリケーション全体に提供されます。

plugins/inversify.ts
import 'reflect-metadata'
import container from '../configs/inversify.config'

export default defineNuxtPlugin(nuxtApp => {
  nuxtApp.provide('container', container)
})

これにより、コンテナコンポーネント、部分コンポーネント、その他の任意の場所から useNuxtApp().$container でアクセスできます。

アーキテクチャの全体像

各機能は、次のレイヤーで構成されます。

  1. Repository層 - データアクセス
  2. ViewModel層 - 状態管理
  3. Presenter層 - プレゼンテーションロジック
  4. Usecase層 - ビジネスロジック
  5. Gateway層 - 外部APIとの境界

各層はインターフェース経由で結合され、DIコンテナが依存関係を解決します。

依存関係の定義

シンボルの定義

各依存関係は Symbol で識別します。

Symbol は JavaScript/TypeScript のプリミティブ型の一つで、一意の識別子を生成します。
文字列キーと違い、Symbol は名前衝突を防ぎ、型安全性を保ちます。

DIコンテナでは、この特性を活用して依存関係を識別します。

features/user/di/symbols.ts
const SearchActionViewModelSymbol = Symbol('User.SearchActionViewModel')
const SearchActionUsecaseSymbol = Symbol('User.SearchActionUsecase')
const SearchActionPresenterSymbol = Symbol('User.ISearchActionPresenter')
const SearchActionRepositorySymbol = Symbol('User.ISearchActionRepository')
const SearchGatewaySymbol = Symbol('User.SearchGateway')

export {
  SearchGatewaySymbol,
  SearchActionViewModelSymbol,
  SearchActionUsecaseSymbol,
  SearchActionPresenterSymbol,
  SearchActionRepositorySymbol,
}

Symbol により、型安全性を保ちつつ名前衝突を避けられます。

DIコンテナのバインド定義

各機能の features/{機能}/di/{機能}.inversify.config.ts で依存関係を定義します。

1. Repository

const bindDIUser = (container: Container) => {
  :
  container
    .bind<ISearchActionRepository>(SearchActionRepositorySymbol)
    .to(SearchActionRepository)
  :
}

export { bindDIUser }
  • インターフェース ISearchActionRepositorySearchActionRepository にマッピングする

2. ViewModel

features/patent/di/user.inversify.config.ts
const bindDIUser = (container: Container) => {
  :
  container.bind(SearchActionViewModelSymbol).to(SearchActionViewModel)
  :
}

export { bindDIUser }
  • ViewModel は状態を持つため、必要に応じてシングルトンにする

3. Presenter

features/patent/di/user.inversify.config.ts
const bindDIUser = (container: Container) => {
  :
  container
    .bind<
      ISearchActionPresenter<ISearchForm, SearchActionViewModel>
    >(SearchActionPresenterSymbol)
    .toDynamicValue(context => {
      const viewModel: SearchActionViewModel = context.get(
        SearchActionViewModelSymbol,
      )
      return new SearchActionPresenter(viewModel)
    })
  :
}

export { bindDIUser }
  • toDynamicValueViewModel に依存する Presenter を動的生成
  • コンテナから ViewModel を取得し、コンストラクタに注入

4. Usecase

features/user/di/patent.inversify.config.ts
const bindDIUser = (container: Container) => {
  :
  container
    .bind<IActionUsecase>(SearchActionUsecaseSymbol)
    .toDynamicValue(context => {
      const repository: ISearchActionRepository = context.get(
        SearchActionRepositorySymbol,
      )
      return new SearchActionUsecase(repository)
    })
  :
}

export { bindDIUser }
  • Repository に依存する Usecase を動的に生成する
  • Repository を取得して注入する

5. Gateway (最上位の依存性解決)

features/patent/di/patent.inversify.config.ts
const bindDIUser = (container: Container) => {
  :
  container
    .bind<
      IDataGateway<ISearchForm, SearchActionViewModel>
    >(PatentSearchGatewaySymbol)
    .toDynamicValue(context => {
      const usecase: IActionUsecase = context.get(SearchActionUsecaseSymbol)
      const presenter: ISearchActionPresenter<
        ISearchForm,
        SearchActionViewModel
      > = context.get(SearchActionPresenterSymbol)
      return new PatentSearchGateway<ISearchForm, SearchActionViewModel>(
        usecase,
        presenter,
      )
    })
  :
}

export { bindDIUser }
  • UsecasePresenter に依存する Gateway を動的に生成する
  • 外部インターフェースとして機能する

依存関係グラフ

Gateway
  ├── Usecase
  │     └── Repository
  └── Presenter
        └── ViewModel

この階層により、各層が適切に分離され、DIコンテナが各依存関係を自動解決します。

どこからでも呼び出せる

DIコンテナはグローバルに提供されるため、コンテナコンポーネント、部分コンポーネント、その他の任意の場所から利用できます。

コンテナコンポーネントからの使用例

features/user/containers/UserContainer.vue
const performSearchAction = async (
  formData: ISearchForm,
): Promise<SearchActionViewModel> => {
  const { $container } = useNuxtApp()
  showLoading()
  try {
    const gateway: SearchGateway<ISearchForm, SearchActionViewModel> =
      $container.get(SearchGatewaySymbol)
    return await gateway.execute(formData)
  } finally {
    hideLoading()
  }
}

コンテナから Gateway を取得し、execute を呼び出します。
コンテナが依存関係を自動解決します。

部分コンポーネントからの使用例

部分コンポーネントからも同様に利用できます。

features/user/components/UserProfile.vue
// 部分コンポーネントからの使用例
<script setup lang="ts">
const { $container } = useNuxtApp()

const fetchUserData = async () => {
  // 部分コンポーネントからでもDIコンテナにアクセス可能
  const gateway = $container.get(SearchGatewaySymbol)
  const viewModel = await gateway.execute(formData)
  // 処理を続ける...
}
</script>

このように、コンポーネントの種類を問わず、どこからでもDIコンテナにアクセスし、依存関係を解決できます。

Gatewayの動作フロー

features/user/gateways/SearchGateway.ts
  async execute(request: T1): Promise<T2> {
    this._presenter.setFormData(request)
    await this._usecase.execute(this._presenter)
    return this._presenter.viewModel
  }
  1. Presenter にフォームデータを設定する
  2. Usecase を実行する
  3. ViewModel を返す

各ステップで依存関係が解決され、柔軟な処理が実現されます。

DIコンテナの初期化

DIコンテナは、次の定義で初期化されます。

configs/inversify.config.ts
import { Container } from 'inversify'
import 'reflect-metadata'
import { bindDIUser } from '@features/user/di/user.inversify.config'

// DIコンテナを構築する
const container = new Container()
bindDIUser(container)

export default container

各機能の bindDI{機能} 関数を呼び出し、依存関係を登録します。
新しい機能を追加する場合も、同様に bindDI{新機能} を追加するだけです。

DIコンテナによる柔軟性の実現

1. 実行時依存性解決

toDynamicValue を使うことで、実行時に依存関係を解決できます。
これにより、環境や条件に応じて異なる実装を注入できます。

2. インターフェースベースの設計

インターフェースを経由することで、実装を切り替えても影響を最小化できます。
テスト時はモック、本番では実装を注入するなど、柔軟に対応できます。

3. 階層的な依存関係の管理

複雑な依存関係も、コンテナが自動解決します。各層の責務を明確にしつつ、柔軟な構成を実現します。

4. 機能ごとの独立性

features/{機能} 構成により、各機能を独立して管理できます。
各機能のDI設定を分離することで、機能間の影響を最小化し、保守性を向上させます。

5. どこからでもアクセス可能

DIコンテナがグローバルに提供されるため、コンテナコンポーネント、部分コンポーネント、その他の任意の場所から同じ方法で依存関係を解決できます。
これにより、コードの一貫性と再利用性が向上します。

メリット

  1. テスタビリティの向上:モックを注入可能で、単体テストが容易
  2. 疎結合の実現:インターフェース経由の依存により変更に強い
  3. 保守性の向上:依存関係が明確で、変更時の影響範囲が把握しやすい
  4. 再利用性の向上:コンポーネントを再利用しやすい
  5. 柔軟な依存性解決:実行時に依存関係を解決し、環境や条件に応じた実装を選択可能
  6. 機能ごとの独立性features/{機能} 構成により、各機能を独立して管理
  7. グローバルアクセス:コンテナコンポーネント、部分コンポーネント、その他の任意の場所から同じ方法でアクセス可能

まとめ

DIコンテナを活用することで、フロントエンドでも柔軟な依存性解決を実現できます。
features/{機能} 構成により、各機能を独立して管理し、DIコンテナで依存関係を解決できます。

DIコンテナを導入することで、以下のメリットが得られます。

  • 宣言的な依存関係管理:依存関係をコード上で明確に定義
  • 型安全性の確保:TypeScript と組み合わせて型安全性を維持
  • テスト容易性の向上:モック注入により単体テストが容易
  • 柔軟な依存性解決:実行時に依存関係を解決し、環境に応じた実装を選択可能
  • クリーンアーキテクチャの実現:各層の責務を明確に分離
  • 機能ごとの独立性features/{機能} 構成により、各機能を独立して管理
  • グローバルアクセス:コンテナコンポーネント、部分コンポーネント、その他の任意の場所から同じ方法で依存関係を解決可能

この実装例は、フロントエンドでも依存性注入を活用できることを示しています。

DIコンテナを活用することで、柔軟で保守性の高いコードを実現でき、features/{機能} 構成と組み合わせることで、スケーラブルで保守しやすいアーキテクチャを構築できます。

Discussion