DEV Community

AlvBarros
AlvBarros

Posted on

Dependency Injection in Flutter

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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!
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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();
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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;
        });
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 => "";
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 the AuthenticationProvider 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);
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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");
    }
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
4stpho profile image
Đặng Văn Tứ

Thanks for sharing ❤️

Collapse
 
redrodrigoc profile image
Rodrigo Castro

great post!

Collapse
 
viniciusenari profile image
Vinicius Koji Enari

Great article!

Collapse
 
lliw profile image
William Rodrigues

Nice post!

Collapse
 
brunofndes profile image
Bruno Fernandes

great content cousin.

Collapse
 
cherryramatis profile image
Cherry Ramatis

I love to learn more about mobile development even though I don't work with it, thanks for the awesome content

Collapse
 
phenriquesousa profile image
Pedro Henrique

Thanks for sharing :)

Collapse
 
pdrolucas profile image
Pedro Lucas

Nice

Collapse
 
zoldyzdk profile image
Hewerton Soares

Very nice article!!

Collapse
 
renanvidal profile image
Renan Vidal Rodrigues

great content