Files
minhthu/lib/core/di/MIGRATION_GUIDE.md
2025-10-28 00:09:46 +07:00

12 KiB

Migration Guide to Riverpod DI

This guide helps you migrate from other dependency injection solutions (GetIt, Provider, etc.) to Riverpod.

From GetIt to Riverpod

Before (GetIt)

// Setup
final getIt = GetIt.instance;

void setupDI() {
  // Core
  getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
  getIt.registerLazySingleton<ApiClient>(() => ApiClient(getIt()));

  // Auth
  getIt.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(getIt()),
  );
  getIt.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(
      remoteDataSource: getIt(),
      secureStorage: getIt(),
    ),
  );
  getIt.registerLazySingleton<LoginUseCase>(
    () => LoginUseCase(getIt()),
  );
}

// Usage in widget
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final authRepo = getIt<AuthRepository>();
    final loginUseCase = getIt<LoginUseCase>();
    return Container();
  }
}

After (Riverpod)

// Setup (in lib/core/di/providers.dart)
final secureStorageProvider = Provider<SecureStorage>((ref) {
  return SecureStorage();
});

final apiClientProvider = Provider<ApiClient>((ref) {
  final secureStorage = ref.watch(secureStorageProvider);
  return ApiClient(secureStorage);
});

final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
  final apiClient = ref.watch(apiClientProvider);
  return AuthRemoteDataSourceImpl(apiClient);
});

final authRepositoryProvider = Provider<AuthRepository>((ref) {
  final remoteDataSource = ref.watch(authRemoteDataSourceProvider);
  final secureStorage = ref.watch(secureStorageProvider);
  return AuthRepositoryImpl(
    remoteDataSource: remoteDataSource,
    secureStorage: secureStorage,
  );
});

final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
  final repository = ref.watch(authRepositoryProvider);
  return LoginUseCase(repository);
});

// Usage in widget
class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final authRepo = ref.watch(authRepositoryProvider);
    final loginUseCase = ref.watch(loginUseCaseProvider);
    return Container();
  }
}

Key Differences

Aspect GetIt Riverpod
Setup Manual registration in setup function Declarative provider definitions
Access getIt<Type>() anywhere ref.watch(provider) in widgets
Widget Base StatelessWidget / StatefulWidget ConsumerWidget / ConsumerStatefulWidget
Dependencies Manual injection Automatic via ref.watch()
Lifecycle Manual disposal Automatic disposal
Testing Override with getIt.registerFactory() Override with ProviderScope
Type Safety Runtime errors if not registered Compile-time errors
Reactivity Manual with ChangeNotifier Built-in with StateNotifier

Migration Steps

Step 1: Wrap App with ProviderScope

// Before
void main() {
  setupDI();
  runApp(MyApp());
}

// After
void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

Step 2: Convert Widgets to ConsumerWidget

// Before
class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

// After
class MyPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Container();
  }
}

Step 3: Replace GetIt Calls

// Before
final useCase = getIt<LoginUseCase>();
final result = await useCase(request);

// After
final useCase = ref.watch(loginUseCaseProvider);
final result = await useCase(request);

Step 4: Convert State Management

// Before (ChangeNotifier + Provider)
class AuthNotifier extends ChangeNotifier {
  bool _isAuthenticated = false;
  bool get isAuthenticated => _isAuthenticated;

  void login() {
    _isAuthenticated = true;
    notifyListeners();
  }
}

// Register
getIt.registerLazySingleton(() => AuthNotifier());

// Usage
final authNotifier = getIt<AuthNotifier>();
authNotifier.addListener(() {
  // Handle change
});

// After (StateNotifier + Riverpod)
class AuthNotifier extends StateNotifier<AuthState> {
  AuthNotifier() : super(AuthState.initial());

  void login() {
    state = state.copyWith(isAuthenticated: true);
  }
}

// Provider
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier();
});

// Usage
final authState = ref.watch(authProvider);
// Widget automatically rebuilds on state change

Common Patterns Migration

Pattern 1: Singleton Service

// Before (GetIt)
getIt.registerLazySingleton<MyService>(() => MyService());

// After (Riverpod)
final myServiceProvider = Provider<MyService>((ref) {
  return MyService();
});

Pattern 2: Factory (New Instance Each Time)

// Before (GetIt)
getIt.registerFactory<MyService>(() => MyService());

// After (Riverpod)
final myServiceProvider = Provider.autoDispose<MyService>((ref) {
  return MyService();
});

Pattern 3: Async Initialization

// Before (GetIt)
final myServiceFuture = getIt.getAsync<MyService>();

// After (Riverpod)
final myServiceProvider = FutureProvider<MyService>((ref) async {
  final service = MyService();
  await service.initialize();
  return service;
});

Pattern 4: Conditional Registration

// Before (GetIt)
if (isProduction) {
  getIt.registerLazySingleton<ApiClient>(
    () => ProductionApiClient(),
  );
} else {
  getIt.registerLazySingleton<ApiClient>(
    () => MockApiClient(),
  );
}

// After (Riverpod)
final apiClientProvider = Provider<ApiClient>((ref) {
  if (isProduction) {
    return ProductionApiClient();
  } else {
    return MockApiClient();
  }
});

Testing Migration

Before (GetIt)

void main() {
  setUp(() {
    // Clear and re-register
    getIt.reset();
    getIt.registerLazySingleton<AuthRepository>(
      () => MockAuthRepository(),
    );
  });

  test('test case', () {
    final repo = getIt<AuthRepository>();
    // Test
  });
}

After (Riverpod)

void main() {
  test('test case', () {
    final container = ProviderContainer(
      overrides: [
        authRepositoryProvider.overrideWithValue(mockAuthRepository),
      ],
    );

    final repo = container.read(authRepositoryProvider);
    // Test

    container.dispose();
  });
}

Widget Testing Migration

Before (GetIt + Provider)

testWidgets('widget test', (tester) async {
  // Setup mocks
  getIt.reset();
  getIt.registerLazySingleton<AuthRepository>(
    () => mockAuthRepository,
  );

  await tester.pumpWidget(
    ChangeNotifierProvider(
      create: (_) => AuthNotifier(),
      child: MaterialApp(home: LoginPage()),
    ),
  );

  // Test
});

After (Riverpod)

testWidgets('widget test', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        authRepositoryProvider.overrideWithValue(mockAuthRepository),
      ],
      child: MaterialApp(home: LoginPage()),
    ),
  );

  // Test
});

State Management Migration

From ChangeNotifier to StateNotifier

// Before
class CounterNotifier extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

// Usage
final counter = context.watch<CounterNotifier>();
Text('${counter.count}');

// After
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() {
    state = state + 1;
  }
}

final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

// Usage
final count = ref.watch(counterProvider);
Text('$count');

Benefits of Migration

1. Type Safety

// GetIt - Runtime error if not registered
final service = getIt<MyService>(); // May crash at runtime

// Riverpod - Compile-time error
final service = ref.watch(myServiceProvider); // Compile-time check

2. Automatic Disposal

// GetIt - Manual disposal
class MyWidget extends StatefulWidget {
  @override
  void dispose() {
    getIt<MyService>().dispose();
    super.dispose();
  }
}

// Riverpod - Automatic
final myServiceProvider = Provider.autoDispose<MyService>((ref) {
  final service = MyService();
  ref.onDispose(() => service.dispose());
  return service;
});

3. Easy Testing

// GetIt - Need to reset and re-register
setUp(() {
  getIt.reset();
  getIt.registerLazySingleton<MyService>(() => MockMyService());
});

// Riverpod - Simple override
final container = ProviderContainer(
  overrides: [
    myServiceProvider.overrideWithValue(mockMyService),
  ],
);

4. Better Developer Experience

  • No need to remember to register dependencies
  • No need to call setup function
  • Auto-completion works better
  • Compile-time safety
  • Built-in DevTools support

Common Pitfalls

Pitfall 1: Using ref.watch() in callbacks

// ❌ Wrong
ElevatedButton(
  onPressed: () {
    final user = ref.watch(currentUserProvider); // Error!
    print(user);
  },
  child: Text('Print User'),
)

// ✅ Correct
ElevatedButton(
  onPressed: () {
    final user = ref.read(currentUserProvider);
    print(user);
  },
  child: Text('Print User'),
)

Pitfall 2: Not using ConsumerWidget

// ❌ Wrong - StatelessWidget doesn't have ref
class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final data = ref.watch(dataProvider); // Error: ref not available
    return Container();
  }
}

// ✅ Correct - Use ConsumerWidget
class MyPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final data = ref.watch(dataProvider);
    return Container();
  }
}

Pitfall 3: Calling methods in build()

// ❌ Wrong - Causes infinite loop
@override
Widget build(BuildContext context, WidgetRef ref) {
  ref.read(authProvider.notifier).checkAuthStatus(); // Infinite loop!
  return Container();
}

// ✅ Correct - Call in initState
@override
void initState() {
  super.initState();
  Future.microtask(() {
    ref.read(authProvider.notifier).checkAuthStatus();
  });
}

Pitfall 4: Not disposing ProviderContainer in tests

// ❌ Wrong - Memory leak
test('test case', () {
  final container = ProviderContainer();
  // Test
});

// ✅ Correct - Always dispose
test('test case', () {
  final container = ProviderContainer();
  addTearDown(container.dispose);
  // Test
});

Incremental Migration Strategy

You can migrate gradually:

  1. Phase 1: Add Riverpod

    • Add dependency
    • Wrap app with ProviderScope
    • Keep GetIt for now
  2. Phase 2: Migrate Core

    • Create core providers
    • Migrate one feature at a time
    • Both systems can coexist
  3. Phase 3: Migrate Features

    • Start with simplest feature
    • Test thoroughly
    • Move to next feature
  4. Phase 4: Remove GetIt

    • Once all migrated
    • Remove GetIt setup
    • Remove GetIt dependency

Checklist

  • Added flutter_riverpod dependency
  • Wrapped app with ProviderScope
  • Created lib/core/di/providers.dart
  • Defined all providers
  • Converted widgets to ConsumerWidget
  • Replaced getIt<T>() with ref.watch(provider)
  • Updated tests to use ProviderContainer
  • Tested all features
  • Removed GetIt setup code
  • Removed GetIt dependency

Need Help?

Summary

Riverpod provides:

  • Compile-time safety
  • Better testing
  • Automatic disposal
  • Built-in state management
  • No manual setup required
  • Better developer experience
  • Type-safe dependency injection
  • Reactive by default

The migration effort is worth it for better code quality and maintainability!