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.
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:
- 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. - Forgotten
autoDispose. Generated providers areautoDisposeby 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.