skip to content

In Flutter's architecture guide, how does the provider package act as dependency injection, and how does a widget test inject a fake PaymentService?

level: middleimportance: should knowfreq 35%

answer

  1. the guide recommends provider for DI
  2. services and repositories at the top
  3. view models built in route builders
  4. context.read resolves by static type
  5. wrap the widget under test

basics

~20 s

The guide places services and repositories as Provider objects above the app, and route builders create view models with context.read(). A widget test wraps the screen in a Provider<PaymentService> that creates a FakePaymentService, so every lookup below it finds the fake.

solid answer

~40 s

Flutter's architecture guide recommends the provider package for dependency injection. Its sample app lists services and repositories in a `MultiProvider` around the app in `runApp`; repository providers build their objects with `context.read()` to fetch the services, and go_router route builders create each screen's view model with `context.read()` for its repositories. Classes still receive dependencies through constructors: provider only decides where objects are created and how long they live, since an object provided in the tree lives while that `Provider` stays mounted. Lookup is by **static type**, so the provider must be declared as `Provider<PaymentService>`, not inferred as `Provider<CardPaymentService>`. In a widget test you pump the screen under a `Provider<PaymentService>(create: (_) => FakePaymentService(), child: ...)`.

code

dart · 40 lines
dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';

abstract class PaymentService {
  Future<void> charge({required int cents});
}

class FakePaymentService implements PaymentService {
  final charges = <int>[];
  @override
  Future<void> charge({required int cents}) async => charges.add(cents);
}

class PayButton extends StatelessWidget {
  const PayButton({super.key});

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => context.read<PaymentService>().charge(cents: 450),
      child: const Text('Pay'),
    );
  }
}

void main() {
  testWidgets('Pay charges through the injected fake', (tester) async {
    final fake = FakePaymentService();
    await tester.pumpWidget(
      Provider<PaymentService>(
        create: (_) => fake,
        child: const MaterialApp(home: Scaffold(body: PayButton())),
      ),
    );

    await tester.tap(find.text('Pay'));
    expect(fake.charges, [450]);
  });
}

go deeper

for a junior

Know that the guide recommends provider for DI and that a test can wrap a widget in a provider that supplies a fake.

for a middle

Explain the guide's wiring: services and repositories at the top, view models built in route builders, lookup by static type.

for a senior

Use subtree scoping deliberately for lifetimes and keep provider out of the classes it injects, so they stay testable with plain constructors.

for a principal

Compare tree-scoped injection with a global locator for a large team and choose which one owns object lifetimes.

## Provider as the composition root Flutter's architecture recommendations rate **using dependency injection** as strongly recommended, because it keeps the app free of globally accessible objects, and recommend the **provider** package to do it. The guide's case study wires its sample app this way: 1. In `runApp`, a `MultiProvider` lists every **service** and **repository** as a provider above the whole app. 2. Services are exposed only so repository providers can pass them into repository constructors with `context.read()`. 3. Repositories are exposed so screens can get them. 4. Each screen's **view model** is created in its go_router route builder, again with `context.read()` supplying the repositories, and handed to the screen's constructor. Inside view models and repositories the injected objects stay **private** constructor arguments. Nothing below the composition root imports provider to find its dependencies. ## Scopes and lifetimes A `Provider` is a widget, so what it provides is **scoped to its subtree**: - `create` is called lazily by default, when the value is first read; - the optional `dispose` callback runs when the `Provider` is removed from the tree; - a provider placed around one route rather than the app gives that route its own instance. This is the main difference from a get_it registry: provider lookups need a `BuildContext` below the provider, and lifetimes follow the widget tree rather than explicit registration and reset calls. ## Lookup is by static type provider finds a value by the **type parameter** of the provider, not by the runtime type of the object. The guide's sample casts, as in `AuthRepositoryRemote(...) as AuthRepository`, so the provider is inferred as `Provider<AuthRepository>`. Without that, a `Provider` whose `create` returns `CardPaymentService` becomes `Provider<CardPaymentService>`, and `context.read<PaymentService>()` throws a `ProviderNotFoundException`. provider's own documentation makes the same point for mocks: declare `Provider<Foo>` explicitly. | Declaration | `read<PaymentService>()` finds it? | |---|---| | `Provider<PaymentService>(create: (_) => CardPaymentService())` | yes | | `Provider(create: (_) => CardPaymentService() as PaymentService)` | yes | | `Provider(create: (_) => CardPaymentService())` | no, inferred as `Provider<CardPaymentService>` | ## read versus watch when injecting provider offers `context.read<T>()` and `context.watch<T>()`. For **injection**, `read` is the tool: it fetches the object once without subscribing the widget to changes. provider's own documentation restricts it: - `read` cannot be called inside `StatelessWidget.build` or `State.build`; - it is meant for `create` callbacks, event handlers such as `onPressed`, and other code outside build; - `watch` is for values the widget displays and must rebuild on. The guide's sample follows this: repository providers call `context.read()` inside `create`, and screens call view model methods from gesture handlers. ## Injecting a fake in a widget test For a parking app's `CheckoutScreen`: - if the screen takes its view model in its constructor, as the guide's views do, the test can build `CheckoutViewModel(payments: FakePaymentService())` and pump `CheckoutScreen(viewModel: vm)` with no provider at all; - if the test covers the route wiring, it pumps the router under a `Provider<PaymentService>` that creates the fake, and every `context.read<PaymentService>()` below resolves to it. Each `pumpWidget` builds a fresh tree, so nothing leaks between tests the way a global registry can. ## Where this stops The details of `MultiProvider`, `create` versus `value` constructors and `ChangeNotifierProvider` belong to provider itself. Riverpod's `ProviderScope` overrides solve the same test problem with a different API.

  • Why does the guide create view models in go_router route builders rather than at the top of the tree?
    A view model belongs to one screen. Building it in the route builder, with `context.read()` supplying app-wide repositories, gives each visit a fresh view model while services and repositories stay shared above the router.
  • How does provider-based injection differ from a get_it registry in tests?
    provider values live in the widget tree, so each `pumpWidget` starts with only what the test places there and nothing needs resetting. get_it's registry is global and must be reset or scoped between tests, but it works outside widgets without a `BuildContext`.

saying these in an interview costs you the question

  • A Provider built from CardPaymentService is found by read<PaymentService>().
  • View models should call context.read themselves to fetch repositories.
  • provider replaces constructor injection, so classes need no constructor parameters.
  • Provided objects live for the whole app no matter where the Provider sits.
  • Widget tests must reset provider registrations in tearDown like a global locator.