Mastering dependency injection in Flutter
Mattia Pispisa
Posted on August 10, 2023
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:
...
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));
}
}
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
}
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);
Annotate ConcreteUserRepository
@LazySingleton(
as: UserRepository,
)
class ConcreteUserRepository implements UserRepository {
...
}
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),
);
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.
Posted on August 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.