Skip to content
    Field notes

    Riverpod architecture that survives a real Flutter app

    Most Riverpod tutorials stop at a counter. Here is the layering, provider naming and testing setup that holds up once you have thirty screens and a backend that fails.

    4 min read

    Riverpod's documentation is excellent at explaining what a provider is. It is much quieter about the thing that actually decides whether your app is maintainable at month six: where providers are allowed to live, and what they are allowed to know about each other.

    This is the layering I have converged on after shipping several Flutter apps with it.

    Three layers, one direction

    Every provider belongs to exactly one of three layers, and dependencies only ever point downwards.

    • Data. Talks to the outside world — HTTP, Firestore, secure storage, the platform. Returns domain models. Knows nothing about the UI.
    • Domain. Business rules and derived state. Combines repositories, applies logic, exposes what the UI actually needs. Knows nothing about widgets.
    • Presentation. Screen-scoped state and controllers. Knows about the domain layer and about Flutter.

    The rule that makes this worth having is the one about direction. A repository provider must never watch a controller. The moment it does, you have a cycle you cannot test in isolation, and the "where does this value come from" question becomes a full-text search.

    Name providers after what they return

    userProvider is a bad name because it does not say whether you get a User, a Future<User>, an AsyncValue<User>, or a controller that can mutate one. Three months later you will not remember either.

    // The repository: one instance, no state.
    @riverpod
    AuthRepository authRepository(Ref ref) =>
        AuthRepository(ref.watch(firebaseAuthProvider));
    
    // Derived, read-only, reactive.
    @riverpod
    Stream<User?> currentUser(Ref ref) =>
        ref.watch(authRepositoryProvider).authStateChanges();
    
    // Mutating. The name says so.
    @riverpod
    class SignInController extends _$SignInController {
      @override
      FutureOr<void> build() {}
    
      Future<void> signIn(String email, String password) async {
        state = const AsyncLoading();
        state = await AsyncValue.guard(
          () => ref.read(authRepositoryProvider).signIn(email, password),
        );
      }
    }
    

    Repositories are nouns. Derived values are nouns. Anything that mutates is a Controller or a Notifier, and the suffix is not optional.

    Use code generation, and use it everywhere

    riverpod_generator is not sugar. It removes the two mistakes that cost the most time:

    1. Wrong provider type. Hand-written StateNotifierProvider<Foo, AsyncValue<Bar>> declarations drift from the class they describe. The generator derives the type from the function signature, so it cannot be wrong.
    2. Forgotten autoDispose. Generated providers are autoDispose by default, which is the right default. A provider that outlives the screen that needed it is the most common source of "why is this showing stale data" in Riverpod apps. Opt out explicitly with @Riverpod(keepAlive: true) when you genuinely want a cache, and let the annotation document the decision.

    Mixing generated and manual providers in one codebase is worse than either alone. Pick generated.

    Let AsyncValue do the error handling

    The single biggest readability win is refusing to unpack AsyncValue by hand. No isLoading bools, no nullable error strings, no if (data != null).

    class ProfileScreen extends ConsumerWidget {
      const ProfileScreen({super.key});
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        return ref.watch(currentUserProvider).when(
          loading: () => const LoadingView(),
          error: (error, stack) => ErrorView(
            error: error,
            onRetry: () => ref.invalidate(currentUserProvider),
          ),
          data: (user) => user == null ? const SignedOutView() : ProfileView(user: user),
        );
      }
    }
    

    Two things follow from this that matter more than they look:

    AsyncValue.guard in your controllers means a thrown exception becomes AsyncError instead of an unhandled zone error. You never write a try/catch in a controller again.

    ref.invalidate gives you retry for free. There is no separate refresh method to write, and no risk of the retry path diverging from the initial load path — they are the same code.

    Side effects belong in ref.listen, not build

    Navigation and snackbars are the classic mistake. If you navigate inside build, you will navigate twice — Flutter rebuilds for reasons that have nothing to do with your state changing.

    ref.listen<AsyncValue<void>>(signInControllerProvider, (previous, next) {
      next.whenOrNull(
        error: (error, _) => showErrorSnackBar(context, error),
        data: (_) => context.go('/home'),
      );
    });
    

    build describes what the screen looks like. listen reacts to something changing. Keeping those separate eliminates an entire category of duplicate-navigation bug.

    Testing: override the data layer, nothing else

    The payoff for the layering rule arrives here. Because nothing below the data layer knows about Flutter, and because everything above it depends on repositories through providers, you can test domain logic with a two-line override.

    test('signs the user out when the token is rejected', () async {
      final container = ProviderContainer(
        overrides: [authRepositoryProvider.overrideWithValue(FakeAuthRepository())],
      );
      addTearDown(container.dispose);
    
      await container.read(signInControllerProvider.notifier).signIn('a@b.com', 'wrong');
    
      expect(container.read(signInControllerProvider), isA<AsyncError>());
    });
    

    No widget tester, no pumping, no mock of Riverpod itself. If a test needs to override anything other than a repository, that is a signal that a dependency is pointing the wrong way — go and find it.

    What this costs

    Honestly: some ceremony. A feature that could be one setState becomes a repository, a provider and a controller. For a prototype that is a bad trade.

    It starts paying at roughly the point where two screens need the same data, or where a single network failure has to be handled consistently in four places. That is usually week three. Deciding the layering after that point means moving code while also shipping features, which is the expensive version.