👏

RiverpodとClean Architectureで学ぶ依存注入・DI・Provider設計パターン

に公開

🧭 はじめに

Flutter開発で状態管理にRiverpodを使う場合、**「どの層で使っていいのか/使ってはいけないのか」**を正しく理解していないと、アーキテクチャがすぐに崩壊します。

本記事では、クリーンアーキテクチャの原則に沿って、

  • Providerを使ってよい場所・使ってはいけない場所
  • ViewでのDI(依存注入)の正しい考え方
  • 実践パターン(課金ステータスなどの例)

をわかりやすく整理します。


✅ Providerを使ってよい場所は2つだけ

クリーンアーキテクチャでは、Providerは「依存の組み立て」と「UIでの消費」だけに使います。

┌────────────────────────────┐
│ ① Provider定義層(DIコンテナ) │ ✅ 使ってよい
└────────────────────────────┘
                ↓
┌────────────────────────────┐
│ Domain / Data / ViewModel   │ ❌ 使わない
└────────────────────────────┘
                ↓
┌────────────────────────────┐
│ ② UI層(Widget / View)     │ ✅ 使ってよい
└────────────────────────────┘

① Provider定義層(DIコンテナ)

依存関係を組み立てる場所です。
DataSource → Repository → UseCase → ViewModel の配線はすべてここで完結させます。

// providers.dart
final localDatasourceProvider = Provider<LocalDatasource>((ref) {
  return LocalDatasource(db: ref.read(appDatabaseProvider));
});

final repositoryProvider = Provider<Repository>((ref) {
  return RepositoryImpl(
    local: ref.read(localDatasourceProvider),
    remote: ref.read(remoteDatasourceProvider),
  );
});

final saveLogUseCaseProvider = Provider<SaveLogUseCase>((ref) {
  return SaveLogUseCase(ref.read(repositoryProvider));
});

final timerViewModelProvider =
    StateNotifierProvider<TimerViewModel, TimerState>((ref) {
  return TimerViewModel(useCase: ref.read(saveLogUseCaseProvider));
});

👉 各層は ref を知らずに、コンストラクタ引数として依存を受け取るだけ。


❌ Providerを使ってはいけない場所

  • DataSource
  • Repository
  • UseCase
  • ViewModel

これらはすべて 「純Dartクラス」 にするのが鉄則です。

class LocalDatasource {
  final AppDatabase _db;
  LocalDatasource(this._db);

  Future<List<Item>> getAll() => _db.query('table');
}

class RepositoryImpl implements Repository {
  final LocalDatasource _local;
  final RemoteDatasource _remote;
  RepositoryImpl(this._local, this._remote);

  Future<List<Item>> fetchAll() => _local.getAll();
}

class SaveLogUseCase {
  final Repository _repository;
  SaveLogUseCase(this._repository);

  Future<void> execute(Model model) => _repository.save(model);
}

class TimerViewModel extends StateNotifier<TimerState> {
  final SaveLogUseCase _useCase;
  TimerViewModel(this._useCase) : super(const TimerState());

  Future<void> save(Model model) async {
    await _useCase.execute(model);
    state = state.copyWith(saved: true);
  }
}

理由:

  • Riverpodに依存しないので、DIライブラリを差し替え可能(GetItなど)
  • テストが容易(newで直接モックを注入できる)
  • 変更の波及が最小限

② UI層(View / Widget)

UIは「状態を購読」する場所。
ref.watchref.read を使ってViewModelの状態を取得したり、メソッドを呼び出すのはOKです。

class TimerScreen extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(timerViewModelProvider);
    final vm = ref.read(timerViewModelProvider.notifier);

    return Scaffold(
      body: Column(
        children: [
          Text('残り: ${state.seconds} 秒'),
          ElevatedButton(
            onPressed: vm.start,
            child: const Text('Start'),
          ),
        ],
      ),
    );
  }
}

🧠 ViewでDI(依存注入)する“正しい”タイミング4選

ProviderをView内で使うのは「組み立て」ではなく「消費」目的です。
主なユースケースは次の4つです。


1. 引数付きインスタンス(family)のスコープ決定

final timerViewModelProvider = StateNotifierProvider.family<TimerViewModel, TimerState, String>((ref, goalId) {
  return TimerViewModel(goalId: goalId, useCase: ref.read(saveLogUseCaseProvider));
});

class TimerScreen extends ConsumerWidget {
  const TimerScreen({required this.goalId});
  final String goalId;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(timerViewModelProvider(goalId));
    ...
  }
}

「どのインスタンスを使うか」 をViewで決定するのはOK。


2. サブツリーごとに依存を差し替える(ProviderScope)

ProviderScope(
  overrides: [
    experimentFlagProvider.overrideWithValue(const ExperimentFlag(enabled: true)),
  ],
  child: const FeatureSection(),
);

一部UIだけ別の依存を使いたいときは、Viewでスコープを切る。


3. アプリ共通状態の購読(例:課金ステータス)

final planStateProvider = StreamProvider<PlanState>((ref) {
  return ref.read(planServiceProvider).watchPlan();
});

Widget build(BuildContext context, WidgetRef ref) {
  final plan = ref.watch(planStateProvider).valueOrNull;
  final isPro = plan?.isPro ?? false;

  return isPro ? const ProFeature() : const PaywallBanner();
}

✅ 「アプリ全体の状態(課金・ログイン・言語など)」は
Providerを使ってUIから購読する。


4. UIイベント・副作用の購読

ref.listen(planStateProvider, (prev, next) {
  if (prev?.value?.isPro == false && next.value?.isPro == true) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Thanks for upgrading!')),
    );
  }
});

✅ 状態変化に応じてUIで副作用(ダイアログ、トーストなど)を出すのも正しい使い方。


❌ Viewでやってはいけないこと

  • Providerの「組み立て」(newして依存を注入)はしない
  • RepositoryやUseCaseを直接触る
  • ViewModelの中で ref.read する

これらは「構造の責務」を壊すパターンです。


🧩 課金ステータスの実践パターン

  • 状態の源泉PlanService(RevenueCat/Firebaseなど)
  • グローバルProviderplanStateProvider
  • UIref.watch(planStateProvider) で出し分け
  • UseCaseやViewModel:課金可否は RestrictionService のような抽象で注入

これにより、UI・ビジネスロジック・データ層がきれいに分離されます。


✅ まとめ

Providerの扱い方
Provider定義層 ✅ 依存を組み立てる(ref.read OK)
Domain / Data / ViewModel ❌ Providerを使わない(純Dart)
UI(View / Widget) ✅ 状態購読・family・スコープ・副作用に使う

💡 Providerは「依存の配線」と「UIの消費」に徹する
それ以外はすべて「純Dartクラス」にすることで、
・テスト容易性
・フレームワーク非依存性
・拡張性

が飛躍的に高まります。


📌 まとめメッセージ

Providerは「線をつなぐための道具」であり、
「中身(ビジネスロジック)」を書く場所ではない。

これを守るだけで、あなたのFlutterプロジェクトは
テストしやすく・拡張しやすく・保守しやすい構造へと変わります。

Discussion