Advanced State Management (Bloc & Riverpod)

Dependency Injection with Riverpod

16 min Lesson 11 of 14

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: AutoDisposeProvider tears 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();
  }
}
Note: 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');
  });
}
Tip: You only need to override the layer you want to fake. If you override 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 layerConsumerWidgets and ConsumerStatefulWidgets that watch notifier providers.
Warning: Never create services or repositories directly inside a 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.