
Indie Devlog #04: Migrating 10K Lines of Riverpod to Code Generation
Riverpod is a fantastic state management library. But when every provider in your codebase is hand-written — Provider, FutureProvider, StreamProvider, StateNotifierProvider, ChangeNotifierProvider — you end up with a lot of boilerplate. The declarations look similar but are juuust different enough that you cannot batch-refactor them.
When the riverpod_annotation package matured, I decided to migrate all four active Flutter apps to code-generated providers. It took several weeks of incremental work, touched about 10,000 lines of provider declarations, and taught me things I wish I had known before starting.
What codegen changes
Before the migration, a typical async provider looked like this:
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
return ProfileRepository(
supabase: ref.watch(supabaseClientProvider),
);
});
final profileProvider = FutureProvider.family<UserProfile, String>((ref, id) async {
final repo = ref.watch(profileRepositoryProvider);
return repo.fetchProfile(id);
});
After migration:
@riverpod
ProfileRepository profileRepository(ProfileRepositoryRef ref) {
return ProfileRepository(
supabase: ref.watch(supabaseClientProvider),
);
}
@riverpod
Future<UserProfile> profile(ProfileRef ref, {required String id}) async {
final repo = ref.watch(profileRepositoryProvider);
return repo.fetchProfile(id);
}
The diff is not dramatic on a single provider. But across 200+ providers, the pattern consistency adds up. Every generated provider follows the same structure, same naming convention, same test override pattern.
The migration pattern
I did not attempt a big-bang migration. Each file was migrated individually as I touched it for feature work or bug fixes. The process:
- Add
part '<file>.g.dart'and the@riverpodannotation. - Convert the provider function signature: remove the
(ref)parameter — codegen injects it as the first positional argument on the generated function. - Remove the provider variable declaration (e.g.
final myProvider = ...). - Run
build_runner— the generated file appears alongside the source. - Update all references:
myProviderbecomesmyProviderProvider(codegen appendsProvider). - Run the tests.
What changed in practice
Notifier providers became cleaner
Hand-written Notifier classes needed a separate class definition plus a provider declaration. Codegen collapses them:
@riverpod
class SessionNotifier extends _$SessionNotifier {
@override
SessionState build() => const SessionState.idle();
Future<void> load() async {
state = const SessionState.loading();
state = await AsyncValue.guard(() => ref.watch(sessionRepositoryProvider).fetchAll()).when(
data: (sessions) => SessionState.success(sessions: sessions),
error: (e, _) => SessionState.failure(message: e.toString()),
loading: () => state,
);
}
}
The sessionNotifierProvider is generated automatically. No separate declaration, no naming mismatches between the class and its provider.
Service providers got colocated
This was an unexpected win. The guideline had always been "declare each service's provider in the same file as the service class." But with hand-written providers, discipline slipped. A few files had their provider declared in a central providers.dart barrel, making them hard to find.
With codegen, the provider is generated from the function next to the class. It cannot be anywhere else. The colocation rule became self-enforcing.
AsyncValue.guard() everywhere
The migration was also the excuse to enforce AsyncValue.guard() over try/catch in async notifiers. The pattern is consistent:
state = await AsyncValue.guard(() => repo.fetchData()).when(
data: (value) => MyState.success(data: value),
error: (e, _) => MyState.failure(message: e.toString()),
loading: () => state,
);
No try/catch blocks in providers. No unhandled exceptions escaping to the UI. The error state is always captured.
What broke
Naming collisions
Codegen appends Provider to generated provider names. themeMode becomes themeModeProvider. This collided with an existing themeMode getter on one widget. The fix was renaming: currentThemeModeProvider.
The same issue can happen with family providers. profile(String id) generates profileProvider(id), not profileProvider(id: id). If you have an existing profileProvider that is not a family, you get a conflict. I started using named parameters on family providers to avoid this:
@riverpod
Future<UserProfile> profile(ProfileRef ref, {required String id}) async { ... }
// Generates: profileProvider(id: 'abc')
build_runner speed
With multiple apps sharing packages, build_runner runs got slow. A full clean build across the monorepo took about 90 seconds. The workaround: use --delete-conflicting-outputs only when adding or removing annotations, and rely on incremental builds during development.
Generated file diffs
The .g.dart files are checked into version control. They produce noisy diffs when the annotation changes slightly (e.g. adding @Riverpod(keepAlive: true)). I stopped reviewing the generated diffs in PRs and just verified the source files were correct and the tests passed.
Was it worth it?
Yes, but the value is cumulative. After the migration, adding a new provider takes one annotation and one function signature instead of one variable declaration, one function, and a mental check that I used the right provider type. The compiler catches the mistakes that code review used to catch.
The bigger win was standardization. Before codegen, providers followed conventions loosely. After codegen, every provider in every app follows the exact same pattern. A new contributor (or my future self returning after six months) can open any file and immediately understand how state flows.
For suggestions and queries, just contact me.
