Riverpod設計考察:ビジネスロジック層にRefは渡すべきか?クリーンアーキテクチャとの境界線
はじめに
FlutterでRiverpodを使った開発をしていると、必ず遭遇する設計上の疑問があります。
「RepositoryクラスにRefを渡してもいいの?」
この疑問は単純そうに見えて、実はRiverpodの設計思想とクリーンアーキテクチャの原則が交差する重要なポイントです。本記事では、この問題を深く掘り下げ、なぜアンチパターンとされるのか、そしてどのような設計が推奨されるのかを実践的な観点から解説します。
TL;DR
- ❌ NG: ビジネスロジック層(Repository/Service/UseCase)にRefを渡す
- ✅ OK: Provider関数内でRefを使い、必要な依存関係のみを注入
- 理由: 単一責任の原則、テスタビリティ、フレームワーク非依存性の維持
- 解決策: コンストラクタインジェクション、メソッド引数、Interceptorパターン
- 例外: ドメインロジックが薄いシンプルなアプリでは、Notifier内にAPIロジックを含めることも現実的な選択
なぜ「RepositoryにRefを渡すか」が議論になるのか
この議論が生まれる背景には、Riverpodの便利さゆえの誘惑があります。
// 誘惑的に見えるが問題のあるコード
class UserRepository {
final Ref ref;
UserRepository(this.ref);
Future<User> getCurrentUser() async {
final token = ref.read(authTokenProvider);
return ref.read(apiClientProvider).getUser(token);
}
}
一見すると合理的に見えます。必要な依存関係に簡単にアクセスでき、コードも簡潔です。しかし、この設計には落とし穴があります。
Refとは何か:Riverpodの依存性注入システムの理解
Ref
は、Riverpodにおける依存関係へのアクセスを提供するオブジェクトです。主な機能:
- read: 一度だけ値を読み取る
- watch: 値の変更を監視する
- listen: 値の変更時にコールバックを実行
- invalidate: Providerをリセット
Ref
は本質的にRiverpodのDIコンテナへのアクセサーであり、フレームワーク固有の概念です。
クリーンアーキテクチャの依存性の原則
クリーンアーキテクチャでは、依存性は常に内側(ビジネスロジック)に向かうべきです:
外側の層 → 内側の層(依存の方向)
具体的には:
┌─────────────────────────────────────────┐
│ UI層(Widget) │ ← Riverpod依存OK
│ ↓ │
│ 状態管理層(Riverpod Notifier) │ ← Riverpod依存OK
│ ↓ │
├─────────────────────────────────────────┤
│ ビジネスロジック層(Service/UseCase) │ ← Riverpod非依存
│ ↓ │
│ データ層(Repository実装) │ ← Riverpod非依存
│ ↓ │
│ ドメイン層(Entity/Repository抽象) │ ← Riverpod非依存
└─────────────────────────────────────────┘
注:Repository抽象(インターフェース)はドメイン層に属し、
Repository実装(具象クラス)はデータ層に属します
重要な原則:
- 内側の層は外側の層を知らない
- データ層の実装はドメイン層の抽象に依存する(依存関係逆転の原則)
アンチパターンとされる理由
RepositoryにRefを渡すことがアンチパターンとされる理由を3つの観点から説明します。
1. 依存関係の逆転と責務の混在
クリーンアーキテクチャでは、ビジネスロジック(Repository)はフレームワーク(Riverpod)に依存すべきではありません。
なぜフレームワーク非依存が重要なのか:
-
変更の方向性の原則
- ビジネスロジックは安定した核心部分
- フレームワークは変更される可能性が高い外部の詳細
- 安定したものが不安定なものに依存すると、連鎖的な変更が発生
-
ビジネスロジックの純粋性
- ビジネスルールはFlutterやRiverpodに関係なく存在する
- 「ユーザーを取得する」ロジックは、UIフレームワークとは独立した概念
-
再利用性
- フレームワーク非依存なコードは他のプロジェクトでも使える
- Flutter WebからFlutter Desktopへの移行時も変更不要
- 将来的に別の状態管理ライブラリへの移行も容易
// ❌ 悪い例
class UserRepository {
final Ref ref;
UserRepository(this.ref);
}
// ✅ 良い例
class UserRepository {
final ApiClient apiClient;
UserRepository(this.apiClient);
}
2. テスタビリティの低下
Refを持つRepositoryのテストは複雑になります:
// ❌ Refを使った場合のテスト
test('複雑なセットアップが必要', () async {
final container = ProviderContainer(
overrides: [/* 多くのoverrides */],
);
final repository = container.read(userRepositoryProvider);
// ...
});
// ✅ 純粋な依存性注入の場合
test('シンプルなテスト', () async {
final repository = UserRepository(MockApiClient());
// ...
});
3. レイヤー境界の曖昧化と単一責任の原則の違反
RepositoryにRefを渡すと、以下の問題が発生します:
1. 単一責任の原則(SRP)の違反
// ❌ 複数の責務を持つRepository
class UserRepository {
final Ref ref;
Future<void> updateUserAndRefresh() async {
await apiClient.updateUser(); // データ操作 ✓
ref.invalidate(userListProvider); // 状態管理 ✗
ref.read(notificationProvider).show(); // 通知管理 ✗
}
}
Repositoryが「データアクセス」「状態管理」「通知管理」という複数の責務を持つことになり、変更理由が増えてしまいます。
2. 上層レイヤーへの直接参照が可能になる
// ❌ レイヤー違反の例
class UserRepository {
final Ref ref;
Future<User> getUser(String id) async {
// UI層の状態に直接アクセス ✗
ref.read(loadingProvider.notifier).update(true);
return apiClient.getUser(id);
}
}
問題の本質:
- 単一責任の原則違反: Repositoryが複数の責務を持つ可能性が生じる
- レイヤー違反: 下層から上層への参照が可能になる
- 依存関係の逆転: アーキテクチャの基本原則が崩壊する
- 変更の波及: 状態管理の変更がデータ層に影響する
「でもNotifierも内部でRef使ってるよね?」問題
鋭い指摘です。確かにNotifierも内部でrefを使います:
class TodoListNotifier extends Notifier<List<Todo>> {
List<Todo> build() => [];
しかし、これは本質的に異なります。
Notifierのrefとrepositoryへのref渡しの本質的な違い
Notifierのref:
- フレームワークが管理するインスタンス変数
- Notifierの設計上の責務(状態管理)に含まれる
- ライフサイクルが保証されている
Repositoryへのref渡し:
- 明示的な依存性として渡される
- 単一責任の原則が破られる可能性が生じる
- 上層レイヤーへのアクセスが可能になる
- フレームワーク非依存性が失われる
フレームワーク管理 vs 明示的依存性注入
// Notifier: refは自動的に注入される機能
class TodoNotifier extends Notifier<List<Todo>> {
// refはNotifierの一部
}
// Repository: refは明示的な依存
class TodoRepository {
final Ref ref; // 外部依存 ✗
TodoRepository(this.ref);
}
Notifierにとってrefは「道具」ですが、Repositoryにとっては「依存」になってしまうのです。
推奨される設計パターン
では、どのような設計が推奨されるのでしょうか。
Provider × Repository:純粋なデータ層
// Repositoryは純粋なクラス
class TodoRepository {
final ApiClient apiClient;
final LocalStorage storage;
TodoRepository({required this.apiClient, required this.storage});
Future<List<Todo>> fetchTodos() async {
try {
return await apiClient.getTodos();
} catch (e) {
return storage.getCachedTodos();
}
}
}
// ProviderでDI管理
TodoRepository todoRepository(TodoRepositoryRef ref) {
return TodoRepository(
apiClient: ref.watch(apiClientProvider),
storage: ref.watch(localStorageProvider),
);
}
Notifier:状態管理層としての責務
class TodoList extends _$TodoList {
Future<List<Todo>> build() async {
final repository = ref.watch(todoRepositoryProvider);
return repository.fetchTodos();
}
Future<void> addTodo(String title) async {
final repository = ref.read(todoRepositoryProvider);
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await repository.createTodo(title);
return repository.fetchTodos();
});
}
}
実装例:TodoアプリでのClean Architecture
// 1. Domain層
class Todo {
final String id;
final String title;
final bool completed;
const Todo({required this.id, required this.title, required this.completed});
}
// 2. Repository層
class TodoRepository {
final ApiClient apiClient;
TodoRepository({required this.apiClient});
Future<List<Todo>> fetchTodos() => apiClient.get('/todos');
Future<Todo> createTodo(String title) => apiClient.post('/todos', {'title': title});
}
// 3. Provider設定
TodoRepository todoRepository(TodoRepositoryRef ref) {
return TodoRepository(apiClient: ref.watch(apiClientProvider));
}
// 4. Notifier層
class TodoList extends _$TodoList {
Future<List<Todo>> build() async {
final repository = ref.watch(todoRepositoryProvider);
return repository.fetchTodos();
}
}
よくある誤解と落とし穴
Riverpodを使っていると陥りがちな誤解を解説します。
「RepositoryをNotifierで作ればいい?」→ NO
// ❌ 間違い:Notifier自体をRepositoryとして使う
class TodoRepository extends _$TodoRepository {
void build() {} // Notifierの設計として不自然(状態を返すべき)
Future<List<Todo>> fetchTodos() async {
// Notifier内でrefが使えるが、これはRepositoryの責務ではない
final dio = ref.read(dioProvider);
return dio.get('/todos');
}
Future<void> createTodo(String title) async {
final dio = ref.read(dioProvider);
await dio.post('/todos', data: {'title': title});
// 状態管理とデータアクセスが混在してしまう
ref.invalidate(todoListProvider);
}
}
なぜダメなのか:
- Notifierは状態管理のためのもので、Repositoryには状態がない
-
build()
メソッドで何を返すべきか不明確(voidは設計として不自然) - データアクセスと状態管理の責務が混在
- テスト時にProviderContainerが必要になり複雑化
正解はProvider
を使うことです。
// テストが簡単
test('fetchTodos returns todo list', () async {
final repository = TodoRepository(
apiClient: MockApiClient(),
storage: MockLocalStorage(),
);
when(() => mockApiClient.getTodos())
.thenAnswer((_) async => [Todo(id: '1', title: 'Test')]);
final todos = await repository.fetchTodos();
expect(todos.length, 1);
});
実世界の課題:認証情報のような動的な値の扱い
「でも認証トークンのように常に最新の値が必要な場合は?」という疑問に答えましょう。
「でも認証トークンは常に最新が必要...」問題
認証トークンのような動的な値は、APIリクエストの度に最新の値が必要です。Refを使えば簡単に取得できそうですが...
// ❌ 誘惑的だが問題のあるアプローチ
class UserRepository {
final Ref ref;
UserRepository(this.ref);
Future<User> getProfile() async {
// 実行時に最新のトークンを取得
final token = ref.read(authTokenProvider);
return apiClient.get('/profile', headers: {'Authorization': 'Bearer $token'});
}
}
これでは前述の問題(テスタビリティ、責務の混在など)が発生します。
解決パターン1:メソッド引数として渡す
// 解決パターン1:メソッド引数
class TodoRepository {
final ApiClient apiClient;
TodoRepository({required this.apiClient});
Future<List<Todo>> fetchTodos(String authToken) async {
return apiClient.getTodos(authToken: authToken);
}
}
// Notifierで使用
class TodoList extends _$TodoList {
Future<List<Todo>> build() async {
final repository = ref.watch(todoRepositoryProvider);
final authToken = ref.watch(authTokenProvider);
return repository.fetchTodos(authToken);
}
}
解決パターン2:Provider関数を渡す
// トークン取得関数を渡す
class UserRepository {
final ApiClient apiClient;
final Future<String?> Function() getToken;
UserRepository({
required this.apiClient,
required this.getToken,
});
Future<User> getProfile() async {
final token = await getToken();
return apiClient.get('/profile', headers: {
if (token != null) 'Authorization': 'Bearer $token',
});
}
}
// Providerで関数を注入
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepository(
apiClient: ref.watch(apiClientProvider),
getToken: () => ref.read(authTokenProvider),
);
}
解決パターン3:Interceptorパターン(推奨)
// HTTPクライアントレベルでトークンを自動付与
Dio dio(DioRef ref) {
final dio = Dio();
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = ref.read(authTokenProvider);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
),
);
return dio;
}
// Repositoryはトークンを一切意識しない
class UserRepository {
final Dio dio;
UserRepository({required this.dio});
Future<User> getProfile() async {
final response = await dio.get('/profile');
return User.fromJson(response.data);
}
}
一般化:この原則はRepositoryだけの話ではない
Service層への適用
Repositoryと同様に、Service層でもRefを渡すべきではありません:
// ❌ ServiceにRefを渡す
class AuthService {
final Ref ref;
Future<void> signIn(String email, String password) async {
final user = await ref.read(authRepositoryProvider).signIn(email, password);
ref.read(currentUserProvider.notifier).set(user); // 状態管理の責務
}
}
// ✅ 純粋なService
class AuthService {
final AuthRepository repository;
AuthService({required this.repository});
Future<User> signIn(String email, String password) async {
return repository.signIn(email, password);
}
}
UseCase層への適用
クリーンアーキテクチャのUseCase層でも同じ原則が適用されます:
// UseCase層でも同じ原則
class GetUserProfileUseCase {
final UserRepository userRepository;
final AuthRepository authRepository;
GetUserProfileUseCase({required this.userRepository, required this.authRepository});
Future<UserProfile> execute() async {
final user = await authRepository.getCurrentUser();
return userRepository.getProfile(user.id);
}
}
原則の一般化:ビジネスロジック層とフレームワークの分離
フレームワーク依存を避けるべき層:
- Repository(データアクセス層)
- Service(ビジネスロジック層)
- UseCase(アプリケーションロジック層)
- Domain Model(ドメイン層)
フレームワーク依存が許される層:
- Notifier(状態管理層)
- Widget(プレゼンテーション層)
- Provider定義(DI設定層)
実践的なガイドライン
「RepositoryにRefを渡すか」チェックリスト
✅ 以下の場合はRefを渡さない
- データアクセスのみを担当
- 他のProviderをwatch/readしない
- 状態を更新しない
- テストでモック化が必要
⚠️ 以下の場合は慎重に検討
- 認証情報のような動的な値が必要 → 引数やInterceptorパターンを検討
- キャッシュ管理が必要 → 専用のCacheManagerをDI
- 複数のデータソースを統合 → 各データソースを個別にDI
アーキテクチャごとの推奨パターン
// 👍 Clean Architecture
UI Layer: Widget + ref.watch/read
State Layer: Notifier + ref (framework managed)
Domain Layer: UseCase (pure classes)
Data Layer: Repository (pure classes)
Infra Layer: Provider (DI container)
// 👍 MVVM
View: Widget + ref.watch/read
ViewModel: Notifier + ref (framework managed)
Model: Repository (pure classes)
Infra: Provider (DI container)
// 👍 MVC
View: Widget
Controller: Notifier + ref
Model: Repository (pure classes)
Infra: Provider (DI container)
現実的な判断:すべてのアプリに必要なのか?
ドメインロジックが薄いアプリの場合
「APIからデータを取得して表示するだけ」のようなシンプルなアプリでは、厳密な層分離が過剰設計になることもあります。
シンプルなアプリでの現実的な選択肢:
// Option 1: NotifierにAPIロジックを含める(シンプルなケース)
class TodoList extends _$TodoList {
Future<List<Todo>> build() async {
final dio = ref.watch(dioProvider);
final response = await dio.get('/todos');
return (response.data as List).map((e) => Todo.fromJson(e)).toList();
}
Future<void> addTodo(String title) async {
final dio = ref.read(dioProvider);
await dio.post('/todos', data: {'title': title});
ref.invalidateSelf();
}
}
この選択が妥当な場合:
- ビジネスロジックがほとんどない
- データの取得・表示が主な機能
- チーム規模が小さい
- アプリの成長が見込まれない
将来を見据えた設計
ただし、以下の場合は最初から層分離を検討すべきです:
-
アプリが成長する可能性がある
- 機能追加が予定されている
- ビジネスロジックが増える見込み
-
チーム開発
- 複数人での開発
- 責任範囲を明確にしたい
-
テストの重要性が高い
- 金融・医療など信頼性が重要
- 自動テストを充実させたい
段階的な移行戦略
// Step 1: 最初はシンプルに
class UserProfile extends _$UserProfile {
Future<User> build() async {
final response = await ref.watch(dioProvider).get('/user');
return User.fromJson(response.data);
}
}
// Step 2: ロジックが増えたらRepositoryに分離
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepository(apiClient: ref.watch(dioProvider));
}
class UserProfile extends _$UserProfile {
Future<User> build() async {
final repository = ref.watch(userRepositoryProvider);
return repository.getCurrentUser();
}
}
まとめ:責務分離とテスタビリティを重視した設計へ
ビジネスロジック層(Repository、Service、UseCase)にRefを渡すことは、一見便利に見えますが、長期的にはメンテナンス性を損なう可能性があります。
重要な原則:
-
責務の分離:
- ビジネスロジック層:純粋なロジックの実装
- Notifier層:状態管理とUIへの橋渡し
-
テスタビリティ:
- フレームワーク非依存により単体テストが簡単に
- モックの作成が容易
-
移植性:
- ビジネスロジックは他のフレームワークでも再利用可能
- Flutterに限定されない設計
階層ごとの指針:
UI層(Widget) → Riverpod依存OK
↓
状態管理層(Notifier) → Riverpod依存OK
↓
ビジネスロジック層(Service/UseCase) → Riverpod依存NG
↓
データアクセス層(Repository) → Riverpod依存NG
↓
ドメイン層(Entity/Model) → Riverpod依存NG
「でもNotifierもref使ってるよね?」という疑問は正しいですが、それはフレームワークが管理する「機能」であり、ビジネスロジック層にとっては「依存」になるという違いを理解することが重要です。
これらの原則を守ることで、変更に強く、テストしやすく、理解しやすいコードベースを構築できます。Riverpodの便利さを活かしつつ、クリーンアーキテクチャの原則を維持することが、持続可能な開発の鍵となるでしょう。
ベストプラクティス
RiverpodでRepositoryパターンを実装する際のベストプラクティスをまとめます。
1. Providerの使い分け
// ✅ 状態を持たないサービス → Provider
Dio apiClient(ApiClientRef ref) => Dio();
Logger logger(LoggerRef ref) => Logger();
// ✅ 状態を管理するもの → Notifier
class UserNotifier extends _$UserNotifier {
User? build() => null;
void updateUser(User? user) {
state = user;
}
}
2. 依存性注入の原則
// ✅ 必要な依存のみを注入
class UserRepository {
final ApiClient apiClient;
final CacheManager cache;
UserRepository({required this.apiClient, required this.cache});
}
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepository(
apiClient: ref.watch(apiClientProvider),
cache: ref.watch(cacheManagerProvider),
);
}
3. テスタビリティの確保
test('シンプルなテスト', () async {
final repository = UserRepository(
apiClient: MockApiClient(),
cache: MockCacheManager(),
);
when(() => mockCache.get<User>('user_1')).thenReturn(cachedUser);
final result = await repository.getUser('1');
expect(result, equals(cachedUser));
});
4. エラーハンドリング
class TodoList extends _$TodoList {
Future<List<Todo>> build() async {
try {
return await ref.watch(todoRepositoryProvider).fetchTodos();
} catch (e) {
if (e is NetworkException) {
throw const AppException('ネットワークエラー');
}
throw const AppException('データ取得に失敗');
}
}
}
5. 動的な値の扱い方
// ✅ Interceptorパターンで認証情報を自動付与
Dio authenticatedClient(AuthenticatedClientRef ref) {
final dio = Dio();
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
final token = ref.read(authTokenProvider);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
),
);
return dio;
}
Discussion
ご執筆ありがとございます!
大変共感する内容で、完全にできていなかったで取り入れたい思いました。
本題と関係ないですかnotifierのbuild内やrepositoryのprovider内で下層のproviderをwatchするのはなぜでしょうか?
watchはstateを監視する役割だと思っておりnotifierのstateにしか使用しないようにしております。
viewmodelより下層は都度破棄されて良いと思ってますが、何か不都合がありそうでしょうか?
watchするとproviderが保持する値が変化するときにリビルドが起こります。これはnotifierに限りません。watchされるproviderから新たな値が流れうるかをwatchする側はわからない前提で、新しい値が流れた時のためにwatchを使っています。
ご回答ありがとうございます!
私はwatchする側はちゃんと新たな値が流れうるか知っておきたい派なんだとわかりました(パフォーマンス影響や予期せぬリビルドが怖い)
参考になりましたmm