Indie Devlog #04: Migrating 10K Lines of Riverpod to Code Generation

Saturday February 28 2026·3 min read

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:

  1. Add part '<file>.g.dart' and the @riverpod annotation.
  2. Convert the provider function signature: remove the (ref) parameter — codegen injects it as the first positional argument on the generated function.
  3. Remove the provider variable declaration (e.g. final myProvider = ...).
  4. Run build_runner — the generated file appears alongside the source.
  5. Update all references: myProvider becomes myProviderProvider (codegen appends Provider).
  6. 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.

Zuhaib Ahmad © 2026