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.watch
や ref.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など) -
グローバルProvider:
planStateProvider
-
UI:
ref.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