DEV Community

Mattia Pispisa
Mattia Pispisa

Posted on

Mastering dependency injection in Flutter

Depend on the abstract class and not the concrete class

One of the 5 SOLI D principles capitulates that a high-level module should depend only on abstract class not on the implementation. This leads to less coupling between modules (more here).

In this article we will focus on how to apply this pattern in flutter.

What are we going to use

get_it is a service locator used to get at run-time the concrete class of the abstract class we are requesting.
injectable and injectable_generator will be used to annotate and generate the get_it's code (an official example of code generation).

Why injectable?

When the service locator pattern is used throughout the whole application and each abstract class can have several concrete classes it is very useful not to have to think about the implementation of get_it but to let the code generation take care of it.

By importing the libraries the pubspec.yaml will look like the following:

dependencies:
  injectable:
  get_it:
  ...

dev_dependencies:
  injectable_generator:
  ...
Enter fullscreen mode Exit fullscreen mode

A concrete use case

Naive

An application needs to display a list of users and the user 's detail.
An idea might be to create a UserRepository.

// foo.dart

/// business logic module
class HighLevelModule {
  void foo() {
    final users = UserRepository().getUsers();
  }
}

// user.dart

/// user model
class User {
  const User({required this.id, required this.displayName});

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'],
      displayName: json['displayName'],
    );
  }

  final String id;
  final String displayName;
}

// user_repository.dart
class UserRepository {

  Future<List<User>> getUsers() async {
    // Don't take this as an example of how to make an http call
    final response = await http.get(Uri.parse('https://example.com/api/users'));
    final List<dynamic> usersData = json.decode(response.body);

    return usersData.map((userData) => User.fromJson(userData)).toList();
  }

  Future<User> getUser({required String id}) async {
    // Don't take this as an example of how to make an http call
    final response = await http.get(Uri.parse('https://example.com/api/users/$id'));
    return User.fromJson(json.decode(response.body));  
  }
}
Enter fullscreen mode Exit fullscreen mode

In this way the caller (high-level module) will depend on the low-level UserRepository module.

A better approach

Applying dependency inversion to decouple the two modules we can create:

class HighLevelModule {
  void foo(UserRepository repository) {
    // doesn't know the repository implementation
    final users = repository.getUsers();
  }
}

abstract class UserRepository {
    Future<List<User>> getUsers(); 
    Future<User> getUser({required String id});
}

class ConcreteUserRepository implements UserRepository {
  // previous UserRepository code
}
Enter fullscreen mode Exit fullscreen mode

Now magic comes into play

Create a injection.dart file like:

import 'injection.config.dart';

@InjectableInit(
    initializerName: 'init',
    preferRelativeImports: true,
    asExtension: false, 
)
FutureOr<void> configureInjection() => init(getIt);
Enter fullscreen mode Exit fullscreen mode

Annotate ConcreteUserRepository

@LazySingleton(
  as: UserRepository,
)
class ConcreteUserRepository implements UserRepository {
  ...
}
Enter fullscreen mode Exit fullscreen mode

(singleton, lazy)

The code generation command (flutter pub run build_runner watch --delete-conflicting-outputs) will create an injection.config.dart file with the get_it code needed to get the concrete class from the abstract one.

getIt<UserRepository>() will return the most suitable concrete class among those registered.

Multiple implementation

Now that the high-level module no longer depend on implementation, we can use different implementations of UserRepository based on environment variables (Configuring apps with environment).

@LazySingleton(
  as: UserRepository,
  env: ["prod"]
)
class ConcreteUserRepository implements UserRepository {
  ...
}

@LazySingleton(
  as: UserRepository,
  env: ["dev"]
)
class DemoUserRepository implements UserRepository {
   @override
  Future<List<User>> getUsers() async {
    return [User(id: "1",displayName: "A"),User(id: "2",displayName: "B")];
   }

    @override
  Future<User> getUser({required String id}) async {
    return User(id: "1",displayName: "A");
  }
}

// file injection.dart
@InjectableInit(...)
FutureOr<void> configureInjection(String env) => init(
      getIt,
      //will be taken the concrete class that contains the env `env`. 
      environmentFilter: NoEnvOrContains(env),
    );
Enter fullscreen mode Exit fullscreen mode

Now we can test our high-level module by ignoring the user repository implementation.

Conclusion

This concludes the article on dependency inversion.

For more information on dependency inversion in flutter I recommend reading get_it and injectable documentation.

Top comments (2)

Collapse
 
alvbarros profile image
AlvBarros

Hey there! I've also written a post about dependency injection in Flutter.
Yours seems deeper than mine however, more like a tutorial.
Great job!

Collapse
 
mattia profile image
Mattia Pispisa

Thanks you for the feedback 🙂.