In this article I'll attempt to teach you what it is, how to do it and why would you do it, as well as providing examples and a link to a repo on GitHub where you can check the code and try it for yourself. Now, moving on.
According to Wikipedia:
In software engineering, dependency injection is a design pattern in which an object or function receives other objects or functions that it depends on. A form of inversion of control, dependency injection aims to separate the concerns of constructing objects and using them, leading to loosely coupled programs. The pattern ensures that an object or function which wants to use a given service should not have to know how to construct those services. Instead, the receiving 'client' (object or function) is provided with its dependencies by external code (an 'injector'), which it is not aware of.
So, in other words:
- Instead of creating objects inside a class or method, those objects are "injected" from outside;
- The class does not need to know how to create the objects it depends on, it just needs to know how to use them;
- This generates code that is easier to test and is more maintainable.
Like anything in life, DI comes with some Pros and Cons.
Pros:
- Makes your code easier to test, since you can just inject mocks in your classes;
- Makes your code easier to maintain, as changes to the implementation of the injected objects can be made without affecting the class or method that depends on them.
Cons:
- DI can add more complexity to your project, especially if done improperly;
- Injecting dependencies can introduce performance overhead;
- DI can introduce runtime errors, such as null pointer exceptions, if dependencies are not properly managed or injected.
The Car example
So, let's start with some code. Suppose you have a Car class, that has an Engine.
class Car {
Engine? engine;
const Car();
void start() {
engine.start(); // Null reference exception
}
}
For this Car
to work, you need a working Engine
. That, however, is another class that has a bunch of complexities and other requirements that do not concern the car itself.
Following the principles of dependency injection, this is what we can do:
Constructor injection
The dependencies are passed to a class through its constructor.
This pattern makes it clear what dependencies a class require to function, and it ensures that the dependencies are available as soon as the class is created.
If we implement constructor injection in our Car
class:
class Car {
final Engine engine;
const Car(this.engine);
void start() {
engine.start(); // engine is not null!
}
}
Since Car.engine
is final
and also required in the construcotr, we make sure that it will never be null.
void main() {
final engine = Engine();
final car = Car(engine);
car.start();
}
Adding more parts
Now, let's imagine that you're a car manufacturer and you are creating parts of a car. Since cars are not only made of engines, you now have this class structure:
Please note that I'm not a car manufacturer and this is not all the parts a car needs.
class Car {
final Engine engine;
final List<Wheel> wheels;
final List<Door> doors;
final List<Window> windows;
Car(this.engine, this.wheels, this.doors, this.windows);
void start() {
engine.start();
}
void rollDownAllWindows() {
for (var w in windows) {
w.rollDown();
}
}
void openAllDors() {
for (var d in doors) {
d.open();
}
}
// ...
}
Since the engine is final
and must be passed on in the constructor, the class won't compile until you give it a working engine. It doesn't make sense that your doors doesn't work until you have a working engine.
With the construction injection approach, you're only able to have a
Car
instance after you have all the pieces already done, and can not have an "incomplete"Car
.
Setter injection
The dependencies are set on a class through setter methods.
This pattern allows for more flexibility as the dependencies can be set or changed after the class is created.
Whenever you have an instance of Car
, you can just use setEngine
to set an engine to the car. This fixes the previous problem and we can now have a Car
and later give it an engine.
class Car {
Engine? engine;
List<Wheel> wheels;
List<Door> doors;
List<Window> windows;
Car(this.wheels, this.doors, this.windows, {this.engine});
void setEngine(Engine newEngine) {
engine = newEngine;
}
void start() {
engine?.start();
}
// ...
}
Now all you have to do is call setEngine
whenever your engine is ready to be placed in the car. You also must add some validation so that you don't have runtime errors happening in your code. For more information on how to properly prevent these issues, take a look at Null safety in Dart.
Other types of dependency injection
These other types will not be covered in this example, so these are just introductions.
Interface injection
The class implements an interface which defines the methods for injecting the dependencies.
This pattern allows for more abstraction and decoupling of the code, as the class does not have to depend on a specific implementation of the interface.
Ambient context
You may be familiar with the provider pub package
A shared context is used to provide the dependencies to the classes that require them.
This pattern can be useful in situations where multiple classes need access to the same dependencies.
Service locator
You may be familiar with the get_it pub package.
A central registry is used to manage and provide the dependencies to the classes that require them.
This pattern can make it easier to manage dependencies in large applications, but it can also make the code more complex and harded to test.
Ok, but why tho?
In one of my projects I needed to create an authentication layer so that my users can create accounts and authenticate themselves.
Since I was still deciding on which one to use - since it needed to be free and easy to scale - I created a dependency injection structure so that I can easily swap out whenever I'd like to test another authentication service.
This is the structure that I've got:
class AuthenticationRepository {
final AuthenticationProvider provider;
AuthenticationRepository(this.provider);
Future<UserSession?> signIn(String email, String password) {
return provider.signIn(email, password).then((session) {
if (session != null) {
return session;
}
throw 'Failed to authenticate';
}).catchError((error) {
throw error;
});
}
// ...
}
This class has a method signIn
that takes an user's email
and password
, then give it to the corresponding provider. It also returns an UserSession
, class responsible to store the current user's data and authentication token.
class UserSession {
final String username;
final String email;
UserSession({
required this.username,
required this.email,
});
String get sessionToken => "";
}
Take notice of AuthenticationRepository.provider
. It's an instance of the class AuthenticationProvider
. Here's the configuration:
abstract class AuthenticationProvider {
Future<UserSession?> signIn(String email, String password);
}
Since this class is abstract, in order to create a repository that actually works, you need to give it an implementation.
So I have created two classes: FirebaseProvider
and CognitoProvider
. These classes are responsible for managin user authentication with Firebase's and Cognito's APIs respectively.
There's a pub package for Firebase integration and also one for Cognito integration.
These packages, however, do not seamlessly fit into theAuthenticationProvider
abstract class showed in this example.
So now, in order to authenticate, we just need to decide which one we want to use. Imagine you have your AuthenticationRepository
stored in a service locator such as GetIt:
// setting up
GetIt.instance.registerSingleton<AuthenticationRepository>(AuthenticationRepository(CognitoProvider());
// authenticating an user
final auth = GetIt.instance<AuthenticationRepository>();
auth.signIn(email, password);
Testing example
To showcase how you can use DI to make better tests and mock classes easily, here's an example of MockAuthenticationProvider
that enables testing on AuthenticationRepository
.
You can begin by creating the mocked provider:
class MockAuthenticationProvider implements AuthenticationProvider {
static String successPassword = "123";
UserSession? userSession;
MockAuthenticationProvider({this.userSession});
@override
Future<UserSession?> signIn(String email, String password) {
if (password == successPassword) {
return Future.value(userSession);
} else {
return Future.value(null);
}
}
}
Note that the class above has a static successPassword
property. This is so that we can implement success and failure methods, but it is in no way necessary. Feel free to implement any logic that you'd like.
And now you can then create the mock factory:
AuthenticationRepository mockRepository() {
final mockUserSession = UserSession(
username: "mock",
email: "mock@mail.com",
sessionToken: "token",
);
final mockProvider = MockAuthenticationProvider(userSession: mockUserSession);
return AuthenticationRepository(mockProvider);
}
By using this AuthenticationRepository
, we can easily test its methods without needing to integrate with either Cognito nor Firebase. Here's an example of a successful unit test:
test('Should return a valid UserSession', () async {
final repo = mockRepository();
final result = await repo.signIn(
"email", MockAuthenticationProvider.successPassword);
assert(result.sessionToken != null);
});
Note that we're trying to signin with an "email"
and MockAuthenticationProvider.successPassword
, which is a way to force the provider to return an UserSession
.
Now, testing for failures:
test('Should throw if UserSession comes null from provider', () async {
final repo = mockRepository();
try {
await repo
.signIn("email", "incorrect password")
.then((userSession) {
fail("Should throw an exception");
});
} catch (error) {
assert(error.toString() == "Failed to authenticate");
}
});
Ending
And that's it!
Thanks for reading through the end with this article. This is my first here on dev.to, so feel free to leave any feedbacks.
Here's the source code once again. Feel free to open an issue or comment down below.
See ya!
Top comments (12)
Thanks for sharing ❤️
great post!
Great article!
Nice post!
great content cousin.
I love to learn more about mobile development even though I don't work with it, thanks for the awesome content
Thanks for sharing :)
Nice
Very nice article!!
great content