Dependency Injection with Riverpod
Dependency Injection with Riverpod
Dependency injection (DI) is the practice of supplying an object's collaborators from the outside rather than letting it create them itself. In Flutter apps built with Riverpod, providers are the injection mechanism: repositories, HTTP clients, and services are each declared as their own provider, and higher-level notifiers simply list the providers they depend on. This yields clean architecture where every layer is independently testable and interchangeable without touching the widgets.
Why Explicit Dependencies Matter
When a StateNotifier or AsyncNotifier instantiates its own http.Client or ApiService internally, you cannot swap that dependency in a test without monkey-patching global state. By contrast, when the dependency is declared as a provider and read through ref, a test can override any provider with a fake in one line.
- Decoupling: business logic is not tied to a concrete implementation.
- Testability: swap real services for fakes with
ProviderContainer.overrides. - Composability: providers can depend on other providers in an acyclic graph.
- Scoping:
AutoDisposeProvidertears down resources when no widget watches them.
Wiring an HTTP Client and Repository
The pattern has three layers. First, expose the low-level infrastructure (an http.Client). Second, expose the repository that uses it. Third, expose the notifier that uses the repository. Each layer reads the one below it via ref.watch or ref.read.
Three-Layer Provider Chain
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
// --- Layer 1: infrastructure ---
final httpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
// Auto-close the client when the provider is disposed
ref.onDispose(client.close);
return client;
});
// --- Layer 2: repository ---
class PostRepository {
final http.Client _client;
PostRepository(this._client);
Future<List<Post>> fetchPosts() async {
final response = await _client.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
);
if (response.statusCode != 200) {
throw Exception('Failed to load posts');
}
return parsePostsJson(response.body);
}
}
final postRepositoryProvider = Provider<PostRepository>((ref) {
// Repository declares its own dependency on the HTTP client
final client = ref.watch(httpClientProvider);
return PostRepository(client);
});
// --- Layer 3: async notifier ---
final postsNotifierProvider =
AsyncNotifierProvider<PostsNotifier, List<Post>>(PostsNotifier.new);
class PostsNotifier extends AsyncNotifier<List<Post>> {
@override
Future<List<Post>> build() {
// Notifier declares its dependency on the repository
return ref.watch(postRepositoryProvider).fetchPosts();
}
}
ref.watch inside a provider's build function creates a reactive dependency. If httpClientProvider were ever overridden (e.g., in a test), postRepositoryProvider would automatically rebuild with the new client, and in turn postsNotifierProvider would rebuild too.Overriding Providers in Tests
The whole point of DI via Riverpod is that you can replace any provider with a mock at the boundary of a test, without modifying production code. Use ProviderContainer with the overrides parameter to inject fakes.
Injecting a Fake Repository in a Unit Test
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class FakePostRepository implements PostRepository {
FakePostRepository(http.Client _); // ignored — test doesn't use real HTTP
@override
Future<List<Post>> fetchPosts() async => [
Post(id: 1, title: 'Test Post'),
];
}
void main() {
test('PostsNotifier loads posts from repository', () async {
final container = ProviderContainer(
overrides: [
// Swap the real repository with the fake one
postRepositoryProvider.overrideWithValue(
FakePostRepository(http.Client()),
),
],
);
addTearDown(container.dispose);
// Read the notifier and wait for the async build to complete
final state = await container
.read(postsNotifierProvider.future);
expect(state.length, 1);
expect(state.first.title, 'Test Post');
});
}
postRepositoryProvider, Riverpod will use the fake for the notifier too — without touching httpClientProvider at all. Override the smallest scope needed to keep tests fast and focused.Using Family Providers for Parameterised Dependencies
When a repository or service must be scoped to a runtime value (e.g., the current user's ID or a selected tenant), use the .family modifier. The parameter becomes part of the provider's identity, so each unique argument creates a separate, independently cached instance.
Family Provider for a User-Scoped Service
final userRepositoryProvider =
Provider.family<UserRepository, String>((ref, userId) {
final client = ref.watch(httpClientProvider);
return UserRepository(client: client, userId: userId);
});
// Consuming widget
class UserProfileWidget extends ConsumerWidget {
final String userId;
const UserProfileWidget({required this.userId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(userRepositoryProvider(userId));
// repo is a UserRepository scoped to this userId
return Text(repo.toString());
}
}
Clean Architecture: Layers at a Glance
A well-structured Riverpod app maps cleanly to clean architecture layers:
- Infrastructure layer — HTTP clients, database adapters, device APIs — exposed as simple
Provider. - Data layer — repositories that convert raw data into domain models — also
Provider, reading infrastructure providers. - Domain layer — use-case notifiers (
AsyncNotifier,Notifier) that read repository providers. - Presentation layer —
ConsumerWidgets andConsumerStatefulWidgets that watch notifier providers.
ConsumerWidget. Widgets are rebuilt frequently; instantiating a new http.Client or opening a database connection on every build wastes resources. Always delegate long-lived objects to providers with appropriate disposal (ref.onDispose).Summary
Riverpod's provider graph is Flutter's idiomatic dependency injection container. By declaring each collaborator as its own provider and having consumers read them via ref, you achieve strict layer separation, zero-boilerplate mocking in tests, and automatic lifecycle management — all without a single manual constructor call inside a widget.