Creating a simple app in Flutter is fast and easy. When things start to scale though we need to be more pragmatic when designing the infrastructure of our app.
Dependency injection is a programming pattern which allows us to better organize and manage classes and dependencies inside our app.
In this post we are going to start from the basics of dependency injection and we will finish by looking at a complete setup using get_it and injectable.
What dependency injection is, and why I should use it
Dart at its core is an Object Oriented language. This brings all the advantages and disadvantages of the paradigm to your application.
One of the most common problems is the following: how to connect together different layers of my application?
Let us see a simple example to make this issue concrete.
Repository pattern in Flutter
The repository pattern is the most common strategy used to connect the UI layer and the business logic layer in a Flutter app.
You want to implement authentication in your app. There are countless solutions for this, but at its core all we generally need is a class
with three functions:
signIn
: verify the user identity and log insignUp
: create a new user accountsignOut
: allow the user to log out from the account
This is the perfect situation to use dart's abstract class
(interface
in other languages):
abstract class AuthRepository {
Future<String> signIn(String email, String password);
Future<String> signUp(String email, String password);
Future<void> signOut();
}
/// Do not focus on the return types, they are not important for this discussion ππΌββοΈ
What is the benefit here? Using an abstract class
we can now be sure that every class that implements
AuthRepository
will allow our users to authenticate.
What
implements
does is forcing a class to define the methods declared inside theabstract class
. This means that every class thatimplements
AuthRepository
is guaranteed to have at leastsignIn
,signUp
, andsignOut
as methods.
Who cares which services the app is using! We may have a SupabaseAuth
, FirebaseAuth
, CustomAuth
. As long as all of these classes implements
AuthRepository
, we can swap different implementations as we need.
/// Do they `implements` `AuthRepository`? Yes, then all of the are accepted π
class SupabaseAuth implements AuthRepository { ... }
class FirebaseAuth implements AuthRepository { ... }
class CustomAuth implements AuthRepository { ... }
This pattern is generally called Repository pattern. This creates a layer of abstraction (abstract class
) between the UI and the data layer.
In fact, the UI does not know where the data comes from. The UI only knows (and cares) about requesting and getting the data in the correct format.
How to access the repository in the UI
Now we come to the core of the problem: how can the UI code access the AuthRepository
?
As always, there are many options to achieve the same result. Let's list what would be the ideal setup:
- Possibility of swapping the concrete implementation (
SupabaseAuth
,FirebaseAuth
,CustomAuth
,TestingAuth
) without accessing the UI code - Avoid initializing a new class every time when need to access the
AuthRepository
- Have a clear outlook on the dependencies between each layer of the application
This is where the Service Locator and Dependency Injection patterns come into play!
At the end of this discussion, we will be able to do something like the code below to sign in the user:
Future<void> _onClickSignIn() async {
await getIt<AuthRepository>().signIn(email, password);
}
Service Locator pattern using get_it
get_it is the most used package to implement the Service Locator pattern in Flutter.
The idea is simple: we want a reliable way to register and access all objects inside our app. get_it
allows us to do exactly that:
- Register all objects (and their dependencies) at startup
- Access all registered objects from a single source
How does this look like in our AuthRepository
example?
First, we need to install get_it
in our project by adding it to pubspec.yaml
:
name: flutter_supabase_complete
description: A new Flutter project.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=2.17.6 <3.0.0"
dependencies:
flutter:
sdk: flutter
get_it: ^7.2.0
Then we need to register the concrete implementation of AuthRepository
during the app startup. We can do that inside main
:
final getIt = GetIt.instance;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
/// Use the global `getIt` instance to register `AuthRepository`
getIt.registerSingleton<AuthRepository>(SupabaseAuth());
runApp(App());
}
We defined the concrete implementation of AuthRepository
as SupabaseAuth
inside the global getIt
object.
Now we can access AuthRepository
everywhere in our UI code using getIt
:
Future<void> _onClickSignIn() async {
await getIt<AuthRepository>().signIn(email, password);
}
Using the repository pattern, the UI code now does not need to know how authentication is implemented: all it cares is that
AuthRepository
has asignIn
method, that's it!
What is dependency injection then?
In our example, AuthRepository
does not have any dependency. In reality we will have other layers responsible for other operations.
To expand on our example, let's introduce a Database Layer. This layer manages the interactions with an outside database.
We can use the same principle as before by defining an abstract class
:
abstract class Database {
Future<String> verifyUserCredentials(String email, String password);
}
We can then create different implementations of Database
:
class SqlDatabase implements Database { ... }
class NoSqlDatabase implements Database { ... }
class LocalStorageDatabase implements Database { ... }
Database
now becomes a dependency of a concrete implementation of AuthRepository
:
class SupabaseAuth implements AuthRepository {
final Database _database;
const SupabaseAuth(this._database);
...
}
We have a new problem: we need to provide (inject) the Database
dependency inside AuthRepository
. Using get_it
we have the following:
final getIt = GetIt.instance;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
/// Use the global `getIt` instance to register `AuthRepository`
getIt.registerSingleton<Database>(SqlDatabase());
getIt.registerSingleton<AuthRepository>(SupabaseAuth(getIt<Database>()));
runApp(App());
}
This pattern is called Dependency Injection: we inject the Database
dependency from outside SupabaseAuth
.
This pattern gives us more control as opposed to initializing the dependency inside the class itself, which would look like this instead:
class SupabaseAuth implements AuthRepository {
final Database _database;
/// Quickly! Look at this and then forget πββοΈ
SupabaseAuth() {
_database = SqlDatabase();
}
...
}
How to manage dependencies at scale: injectable
This is all good and well, but it introduces another great hassle at some point.
Instead of me telling you about it, let me show you (example from real app back in the days):
final serviceLocator = GetIt.instance;
Future<void> init() async {
serviceLocator.registerFactory(
() => QuizBloc(
getQuizId: serviceLocator(),
getQuizMeaningList: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => UserBloc(
getSingleUser: serviceLocator(),
getUserLocal: serviceLocator(),
logoutUserLocal: serviceLocator(),
experience: serviceLocator(),
updateUserExperienceAndActivity: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => CollectionBloc(
getCollectionList: serviceLocator(),
userBloc: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => SearchBloc(
getAllSearchMeaningList: serviceLocator(),
percentageConverter: serviceLocator(),
collectionBloc: serviceLocator(),
userBloc: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => FullHistoryBloc(
getFullHistoryList: serviceLocator(),
dateTimeFormatter: serviceLocator(),
searchBloc: serviceLocator(),
userBloc: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => PacketBloc(
getSavedPacketList: serviceLocator(),
fullHistoryBloc: serviceLocator(),
),
);
serviceLocator.registerFactory(
() => ExamBloc(
chooseTranslationDirection: serviceLocator(),
),
);
serviceLocator.registerFactory(
() => MemorytBloc(),
);
serviceLocator.registerFactory(
() => PuzzleCellBloc(
boardSize: BOARD_SIZE,
),
);
serviceLocator.registerFactory(
() => PuzzleAnswerBloc(),
);
serviceLocator.registerFactory(
() => PuzzleBloc(
boardSize: BOARD_SIZE,
getPuzzleTree: serviceLocator(),
getLettersBoard: serviceLocator(),
),
);
serviceLocator.registerFactory(
() => AddPacketBloc(
localDataSource: serviceLocator(),
),
);
serviceLocator.registerFactory(
() => SingleQuizHistoryBloc(
getSingleQuizAnswerList: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => NavigatorManagerBloc(
navigatorKey: serviceLocator(),
userBloc: serviceLocator(),
dateTimeFormatter: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => DialogManagerBloc(),
);
serviceLocator.registerLazySingleton(
() => GetQuizMeaningList(
quizRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetQuizId(
quizRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => UpdateUserExperienceAndActivity(
repository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => PostQuizAnswer(
answerRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetAllSearchMeaningList(
repository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetFullHistoryList(
repository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => LogoutUserLocal(
userRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetSingleQuizAnswerList(
repository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => ChooseNextTraining(
managerRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => ChooseNextTrainingComplete(
managerRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => RemoveTraining(
managerRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => RemoveTrainingComplete(
managerRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => ChooseTranslationDirection(
managerRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetCollectionList(
collectionRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetSingleUser(
userRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetUserLocal(
userRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetSavedPacketList(
packetRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetPuzzleTree(
puzzleRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton(
() => GetLettersBoard(
puzzleRepository: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<QuizRepository>(
() => QuizRepositoryImpl(
networkInfo: serviceLocator(),
remoteDataSource: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<AnswerRepository>(
() => AnswerRepositoryImpl(
networkInfo: serviceLocator(),
remoteDataSource: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<StatsRepository>(
() => StatsRepositoryImpl(
networkInfo: serviceLocator(),
remoteDataSource: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<CollectionRepository>(
() => CollectionRepositoryImpl(
networkInfo: serviceLocator(),
localDataSource: serviceLocator(),
remoteDataSource: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(
networkInfo: serviceLocator(),
localDataSource: serviceLocator(),
remoteDataSource: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<PacketRepository>(
() => PacketRepositoryImpl(
localDataSource: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<PuzzleRepository>(
() => PuzzleRepositoryImpl(),
);
serviceLocator.registerLazySingleton<StorageAnswerRepository>(
() => StorageAnswerRepositoryImpl(),
);
serviceLocator.registerLazySingleton<QuizRemoteDataSource>(
() => QuizRemoteDataSourceImpl(
client: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<StatsRemoteDataSource>(
() => StatsRemoteDataSourceImpl(
client: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<AnswerRemoteDataSource>(
() => AnswerRemoteDataSourceImpl(
client: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSourceImpl(
client: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<UserLocalDataSource>(
() => UserLocalDataSourceImpl(
sharedPreferences: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<PacketLocalDataSource>(
() => PacketLocalDataSourceImpl(
sharedPreferences: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<CollectionRemoteDataSource>(
() => CollectionRemoteDataSourceImpl(
client: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<CollectionLocalDataSource>(
() => CollectionLocalDataSourceImpl(
sharedPreferences: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<NetworkInfo>(
() => NetworkInfoImpl(
serviceLocator(),
),
);
serviceLocator.registerLazySingleton<Experience>(
() => ExperienceImpl(
percentageConverter: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<AnswerKnowledge>(
() => AnswerKnowledgeLocal(
similarity: serviceLocator(),
percentageConverter: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<AnswerInspector>(
() => AnswerInspectorImpl(
similarityCheck: serviceLocator(),
),
);
serviceLocator.registerLazySingleton<Similarity>(
() => LevenshteinDistance(),
);
serviceLocator.registerLazySingleton<DateTimeFormatter>(
() => FormatHistoryDate(),
);
serviceLocator.registerLazySingleton(() => PercentageConverter());
final sharedPreferences = await SharedPreferences.getInstance();
serviceLocator.registerLazySingleton(() => sharedPreferences);
serviceLocator.registerLazySingleton(() => http.Client());
serviceLocator.registerLazySingleton(() => DataConnectionChecker());
serviceLocator
.registerLazySingleton<GlobalKey<NavigatorState>>(() => GlobalKey());
}
At a certain scale manually managing all your dependencies become a nightmare.
Rest assured, a solution exists! It is called injectable.
injectable
was first released on 28 January 2020. I was developing apps in Flutter way before that date: the release of this package saved the day going forward ππΌ
How to configure injectable
injectable
is a code generator package that relies on build_runner
. injectable
inspects all your dependencies and automatically generates a get_it
configuration, so you do not need to manually define it.
First, install injectable
, injectable_generator
, and build_runner
:
name: flutter_supabase_complete
description: A new Flutter project.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=2.17.6 <3.0.0"
dependencies:
flutter:
sdk: flutter
injectable: ^1.5.3
get_it: ^7.2.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.2.0
injectable_generator: ^1.5.4
Note:
injectable
relies onget_it
to work, so you need to installget_it
as well
Then create an injectable.dart
file inside lib
with the following basic configuration:
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
final getIt = GetIt.instance;
@InjectableInit()
void configureDependencies() => $initGetIt(getIt);
$initGetIt
does not exists at the moment. It will be generated by injectable
when running the build command:
flutter pub run build_runner build
Finally, remember to call configureDependencies
during the app startup inside main.dart
:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Dependency injection (injectable)
configureDependencies();
runApp(App());
}
Defining concrete implementations with injectable
Now we simply need a way to inform injectable
of our classes and objects.
Easy! Based on our example, we just need to annotate our class with @Injectable
:
@Injectable(as: AuthRepository)
class SupabaseAuth implements AuthRepository {
final Database _database;
...
}
Now injectable
will treat any instance of AuthRepository
as SupabaseAuth
. The same goes for Database
:
@Injectable(as: Database)
class SqlDatabase implements Database { ... }
Done! injectable
will automatically resolve all the dependencies for us. We now have the ease of use of get_it
, without the hassle of manually organizing dependencies!
This is just an introduction on what get_it
, injectable
, and dependency injection can achieve for you.
There are many more usecases covered by these packages (environments, lazy singletons, factories). I suggest you to dive deeper beyond the basics to explore how injectable
can help you developing your app with ease.
As always, you can follow me on Twitter at @SandroMaglione for more updates and advanced topics on Dart and Flutter.
Thanks for reading.