Search code examples
flutterdartriverpod

Best Practice when Accessing a Riverpod Provider within a Repository Class


My Flutter app uses Riverpod for state management and the AppAuth package to utilize oAuth for authentication. I've created an AppAuth provider to initialize FlutterAppAuth and access its methods within my AuthRepository class. My question is what is the best practice to access the AppAuth methods within my AuthRepository. I've come up with two options below.

  1. Dependency injection in AuthRepository
  2. Pass provider ref to AuthRepository

I've read through the documentation and both options appear to follow standards, as passing a ref (option 2) is allowed. However, option 1 makes use of ref.watch which appears to be recommended over ref.read (option 2). But, in the documentation it states to use ref.read when calling a method that will update state (option 2). Finally, since my appAuthProvider is just providing an instance of FlutterAppAuth I don't believe it will ever change, therefore, my authRepositoryProvider would never rebuild with it watching my appAuthProvider (option 1). So, my thinking is the ref.watch is unnecessary.

Below are the two options laid out in code. Thank you!

// Option 1 - Dependency injection in AuthRepository
// AppAuth provider, access to AppAuth methods
final appAuthProvider = Provider<FlutterAppAuth>(
  (ref) => const FlutterAppAuth(),
);

// Auth repository provider, used in StateNotifier to access repository methods
final authRepositoryProvider = Provider<AuthRepository>((ref) {
  final appAuth = ref.watch(appAuthProvider);
  return AuthRepository(appAuth);
});

// Auth repository, use AppAuth methods
class AuthRepository {
  final FlutterAppAuth _appAuth; // Dependency

  const AuthRepository(
    this._appAuth,
  );

  Future<bool> signIn() async {
    // Call method from appAuth dependency
    final authResult = await _appAuth.authorizeAndExchangeCode(
      AuthorizationTokenRequest(
        '<client_id>',
        '<redirect_url>',
        discoveryUrl: '<discovery_url>',
        scopes: ['openid', 'profile', 'email', 'offline_access', 'api'],
      ),
    );

    if (authResult != null) {
      return true;
    }

    return false;
  }
}
// Option 2 - Pass provider ref to AuthRepository
// AppAuth provider, access to AppAuth methods
final appAuthProvider = Provider<FlutterAppAuth>(
  (ref) => const FlutterAppAuth(),
);

// Auth repository provider, used in StateNotifier to access repository methods
final authRepositoryProvider = Provider<AuthRepository>(
  (ref) => AuthRepository(ref),
);

// Auth repository, use AppAuth methods
class AuthRepository {
  final Ref _ref; // Pass Ref

  const AuthRepository(
    this._ref,
  );

  Future<bool> signIn() async {
    // Read AppAuth provider directly to call method
    final authResult = await _ref.read(appAuthProvider).authorizeAndExchangeCode(
          AuthorizationTokenRequest(
            '<client_id>',
            '<redirect_url>',
            discoveryUrl: '<discovery_url>',
            scopes: ['openid', 'profile', 'email', 'offline_access', 'api'],
          ),
        );

    if (authResult != null) {
      return true;
    }

    return false;
  }
}

Solution

  • The first option better corresponds to the principles of SOLID. For example, the "Single responsibility principle" is better observed here, since you explicitly restrict the class to a single dependency final FlutterAppAuth _appAuth;. In the second case, you provide a Ref _ref which can be used to get any other dependency within the class (i.e. inadvertently add any other dependencies up to CircularDependencyError).

    Well, you can say that this is extremely convenient - we get any dependencies anywhere, anytime. Besides convenience, this is a huge problem, because at least think about how you will test it? In the first option, you can test the AuthRepository class without touching Riverpod at all.

    Further, in my opinion, it is very convenient when you see all the dependencies of a given class in one place - in the constructor. You could even do something like this:

    late final FlutterAppAuth _appAuth;
    
    AuthRepository(
        Ref ref,
      ) {
      _appAuth = ref.read(appAuthProvider);
    }
    

    but would be stripped of const + using ref.read means your class will not be rebuilt when FlutterAppAuth changes. And although this is the worst way, it clearly indicates which services / repositories this class will work with.

    In the second case, each time you have to run your eyes over the entire class code and remember which ref.read we pull and what services we use.

    Explicit is better than implicit.

    Summarizing, we can say that the second approach is more convenient to use in fast mvp projects, where the speed of writing prevails over the quality of the code and the project is small. The second method is suitable for a higher-quality application, where responsibilities are clearly observed and there is a need for testing.


    Starting with Riverpod 2.0, notice the (Async)NotifierProvider classes, which is more of a 2nd option but removes the boilerplate code.