Marcos Sevilla
Marcos Sevilla

Marcos Sevilla

Cleaner Flutter Vol. 9: Injecting dependencies

Cleaner Flutter Vol. 9: Injecting dependencies

“Ain't got nobody that I can depend on” — Carlos Santana

Marcos Sevilla's photo
Marcos Sevilla
·Feb 19, 2022·

6 min read

Table of contents

  • Identify dependencies
  • What is dependency injection
  • A pattern to follow
  • Ways of injection in our apps
  • What to import in our app

I’m going to start this article by mentioning an important algorithmic paradigm that sometimes slips through our hands when designing a system: divide and conquer.

In the first volume, I used an analogy to express how I like to think about projects, apps, and software in general: LEGOs. Those small blocks can fit together to form a bigger structure, more importantly, having the possibility to take one block and reuse it for another structure that we can build later. Talking about efficiency here.

A project can grow unexpectedly and that brings us the popular scalability problem. But how can we plan against it?

Yes, you guessed, dividing and conquering. By splitting our code into small pieces, we can affect fewer parts of our code at the same time when maintaining it. It makes even more sense when we want to isolate one component in an environment that only mocks its dependencies to test expected behaviors atomically.

Identify dependencies

Ok, now that we mentioned dependencies, let’s talk about them.

Now that we have our architecture, we need to follow its patterns. As we already know, we have our domain layer which has repository contracts, and then the data layer that implements those contracts.

Each one of these layers is a dependency on our app. In our app, we have two parts of our architecture that we will cover in the next volume of this series, but for now, we’ve seen data and domain. The data layer is a dependency of the repository layer and can be configured on the app to be used in the domain layer by our repository.

Usually, Dart packages and Flutter plugins are data sources that should be dependencies of our repository in the domain layer, so we don’t need to create a separate package to handle the data layer. But in some cases, the direct use of a package/plugin it’s not enough abstraction to be a dependency for our repository, that’s why we create data sources or API packages. You can see an example of the latter in the flutter_todos example on the bloc library.

In that same example, we can see that abstracting our data layer can allow us to create different implementations of it, an aspect that we remarked on volume 7.

💡 One thing to note is that the abstraction of these dependencies varies depending on the project, sometimes using a package or plugin as a repository dependency can be enough abstraction depending on what we want to do.

What is dependency injection

But first, what is dependency injection? This explanation from freeCodeCamp.com works for us:

So, transferring the task of creating the object to someone else and directly using the dependency is called dependency injection.

Translating... It’s a technique (like Kamehameha) that is based on recognizing a class dependency and not creating it inside that class but instead receiving that class as a property that is created and provided from another source.

See this:

class Foo {
  final bar = Bar();

  void doAnything() {
    bar.doSomething();
  }
}

class Bar {
  void doSomething() {}
}

In this case, Foo depends on Bar, and to use it, it creates an object inside itself to later use one of its methods. We recognize Bar as a dependency of Foo, but Foo it's not following dependency injection.

A pattern to follow

Let’s fix the previous approach by defining an adequate pattern for our code.

class Foo {
  Foo({required this.bar});

  final Bar bar;

  void doAnything() {
    bar.doSomething();
  }
}

class Bar {
  void doSomething() {}
}

Yup, that looks better.

If we ask for our dependency on the object’s constructor, we can give it an instance created in another part of our code and comply with the DI pattern.

This is especially useful when testing our class, that way, we can create a mock instance of our dependency and set up specific behavior to test if our class behaves the way we want to based on the behavior we define.

But still, this can be improved.

https://media.giphy.com/media/26xBKqeFFspRZjDTW/giphy.gif

You are probably wondering, what’s wrong with that approach?

void main() {
  final foo = Foo(bar: Bar());

  foo.bar.doSomething();
}

Oh no, that doesn’t look good. Our bar property is public, so we can access its methods without using foo explicitly!

We can fix it by making our property private:

class Foo {
  Foo({required Bar bar}) : _bar = bar;

  final Bar _bar;

  void doAnything() {
    _bar.doSomething();
  }
}

class Bar {
  void doSomething() {}
}

void main() {
  final foo = Foo(bar: Bar());

  foo.doAnything();
}

This way, we define a variable of type Bar in our constructor’s scope and then assign its value to our private variable.

Here’s another tip: if you want to have a fallback instance for your dependency and not make it required in your constructor, you can define the constructor instance as nullable.

class Foo {
  Foo({Bar? bar}) : _bar = bar ?? Bar();

  final Bar _bar;

  void doAnything() {
    _bar.doSomething();
  }
}

class Bar {
  void doSomething() {}
}

void main() {
  final foo = Foo();

  foo.doAnything();
}

This will allow you to instantiate your Foo object without passing an instance of Bar. This comes in handy when your dependency doesn’t need any configuration and can be used with its default behavior.

One thing to take into consideration with this last tip is that some of your dependencies will require an explicit instance configured for your class, like a data source that depends on a Dio object (see Better HTTP with Dio) and we assume that object already has a base URL. We can require a new instance to force ourselves to pass an object with that already defined.

And before we move on, please don’t do this...

class Foo {
  Foo(this._bar);

  final Bar _bar;

  void doAnything() {
    _bar.doSomething();
  }
}

class Bar {
  void doSomething() {}
}

You straight-up can’t use named parameters with this approach, and that can be a problem if your class needs to support more dependencies and you want to have a clean API. Just, don’t.

Ways of injection in our apps

There are already different alternatives to implement dependency injection in our apps. As we mentioned on Riverpod: Rewriting Provider, most of the packages that need DI to solve problems like state management (which is DI + reactive state components) use package:provider.

Other alternatives use the service locator pattern, like package:get_it. But as we selected package:riverpod and package:bloc as the two state management alternatives to use in our architecture, we will explain briefly how they implement dependency injection.

How Provider does it

To implement DI, package:flutter_bloc has widgets that use package:provider to inject blocs in our widget tree. That’s by using a Flutter widget called InheritedWidget, which allows us to provide properties to its children.

You can check how to use BlocProvider and dependency injection with bloc on the official documentation.

How Riverpod does it

Aiming to improve the way we do DI with provider, riverpod has a unique way to inject dependencies in our apps, I talk more about that on Riverpod: Rewriting Provider.

What to import in our app

If there’s no configuration for our data layer, we want to import only our repository to the app. This happens when the dependency our repository relies on can create an internal instance without us providing one as I mentioned previously.

That said, if you can create your repository in your app without injecting dependencies, you should only import your repository package and any models or functionality from the data source you may need. Like this:

library todos_repository;

// Here you export your repository and 
// the `Todo` model from your API.
export 'package:todos_api/todos_api.dart' show Todo;
export 'src/todos_repository.dart';

On the other hand, if you need to initialize a data source/API dependency when creating your repository in the app project, then feel free to also import that package into your app.

 
Share this