Peter Bryant
Peter Bryant

Peter Bryant

Approaches to dependency injection in Flutter

Peter Bryant's photo
Peter Bryant
·May 6, 2022·

8 min read

Approaches to dependency injection in Flutter

Photo by Sara Bakhshi on Unsplash

Subscribe to my newsletter and never miss my upcoming articles

Any object-oriented software project worth it's salt should adopt a dependency injection pattern. But when it comes to Flutter apps, there are far too many options. So what do all these options look like, and which is best?

Side-note: on state management

A lot of the packages and techniques we're covering here cross over into the world of State Management. But dependency injection and state management are not the same thing, and shouldn't be considered as such.

State management is strictly to do with the state of your app's user interface. Should this button be enabled? What's the content of this text field? What page are we on right now?

Dependency injection is concerned with providing each class within your app with the dependencies it needs to function e.g. a Bloc class depending on a Repository.

As it happens, there are a lot of Flutter packages which advertise themselves as both state management packages and dependency injection frameworks. They're not wrong, but this can sometimes cause some confusion when developers conflate the two patterns.

What are we trying to achieve?

Dependency injection can be achieved without any special packages or techniques. Consider this class:

class ClassA {
  // Some implementation...
}

class ClassB {
  final objectA = ClassA();

  // Some implementation...
}

Here, we have two classes: ClassA, and ClassB. ClassB includes an instance of ClassA as part of its implementation; in other words, ClassB depends on ClassA.

However, the code above has some problems.

Let's say we change the implementation of ClassA such that its constructor changes. We now have to go through our code and find every instance of ClassA and change its constructor call. That's hugely tedious and will very quickly become impossible to maintain.

Another issue is to do with testing. In our implementation above, ClassB is deciding exactly what version of ClassA it will use. So what about when we want to write unit tests for ClassB? We will also find ourselves testing the implementation of ClassA, which will add lots of complexity to our tests and ultimately make them less useful.

We can solve these problems using the Dependency Injection pattern, which simply involves providing a class's dependencies via its constructor:

class ClassB {
  ClassB(this.objectA);

  final ClassA objectA;
}

Now, we can pass whatever object we want as objectA, including a mocked version of ClassA. It also ensures that if the constructor for ClassA changes, the implementation of ClassB doesn't need to change too. This improves the separation of concerns and makes ClassB independently testable.

We can even take this one step further by extracting ClassA's interface into an abstract version:

class ClassB {
  ClassB(this.objectA);

  final InterfaceA objectA;
}

This ensures that ClassB is entirely independent of the underlying implementation of InterfaceA, which further decreases coupling, and achieves dependency inversion.

We don't actually need frameworks

The obvious conclusion of the definition above is that dependency injection is astonishingly simple. So why are there so many frameworks and packages for it?

The answer is largely about hiding complexity for developers. In large applications, the relationships between classes can become pretty complex. There are a few challenges in particular for Flutter apps:

  1. Environments - how can I make it easy to switch between staging/test dependencies and production ones?

  2. Scopes - how can I control which dependencies should be available to which dependent classes? How do I know whether or not I'm missing a dependency in a given scope?

  3. Lifecycles - how can I understand and control when dependencies should be created, recreated, or destroyed?

A handful of frameworks and patterns exist, and each of them approaches the three concerns above in a slightly different way. I'm going to compare two extremely popular packages which represent the two most common approaches to dependency injection.

Provider

Provider is an extremely popular library/pattern in the Flutter world which takes advantage of Flutter's InheritedWidget to provide dependencies via the widget tree.

This makes it astonishingly simple to manage dependencies. All you have to do is wrap a widget with a Provider:

Provider<ClassA>(
  create: (context) => ClassA(),
  child: MyApp(),
);

Now, any child of the MyApp widget can access the instance of ClassA like this:

context.watch<ClassA>(); // Makes the widget listen to changes in `ClassA` and rebuild
context.read<ClassA>(); // Returns the instance of `ClassA` without listening to it

So how well does Provider address the 3 concerns above?

  1. Environments: Provider doesn't have any built-in mechanism for environment management, which gives you the freedom to manage environment variables however you like. You could have different main.dart files for each environment, or use Dart's fromEnvironment methods to read variables passed in via the flutter build and flutter run commands. It's up to you!

  2. Scopes: You can scope your dependencies using Provider by only wrapping the widgets whose children need access to the given dependency. This makes a lot of sense for Flutter apps! A couple of disadvantages do exist though:

    • There's no way of "ending" a Provider's scope so every descendant widget will always be able to access the dependency. This is honestly fine in practice but is something to consider
    • This can cause difficulties with navigation. Navigating to a new route essentially creates a new tree, so you may end up with problems of navigation destinations not having access to their dependencies. The solution is usually to wrap the whole app with your Provider, which sacrifices some flexibility when it comes to scoping and lifecycle management.
  3. Lifecycles: You get quite a lot of flexibility when it comes to deciding when objects are created by Providers. You can either let the Provider manage creation for you (and you get to decide whether it does lazy instantiation or not), or you can manage the creation of the dependency yourself and use a Provider.value constructor to inject the object into the widget tree. This flexibility covers most requirements you might have when it comes to dependency lifecycle management.

There are some other things to consider when using Provider:

  • There is a limitation in that you can only have one Provider per type in your Widget tree. If you have more than one Provider<ClassA>, then context.read will just retrieve the closest one. This doesn't usually cause too many problems but is something to be aware of.
  • Provider's error messaging is really helpful, which is a bonus when it comes to debugging
  • Provider is the preferred dependency injection solution for the Bloc library, if you like to use that for state management

Overall, I really like the flexibility and simplicity of Provider!

GetIt + Injectable

GetIt describes itself as a service locator for Dart, and when paired with Injectable it becomes a full dependency injection framework, with lots of similarities to Dagger/Hilt from the Android world.

Injectable relies on code generation to automatically write initialization code for GetIt. You can create an "injectable" class by simply annotating it with @injectable (or @singleton).

Instances of dependencies can then be accessed using this syntax: GetIt.I<ClassA>(). Notice that there's no dependency on a BuildContext here; unlike Provider, GetIt dependencies exist outside the widget tree.

How does it handle our three concerns?

  1. Environments: Injectable includes great support for managing environments. You can create a number of Environment objects and use them to annotate your dependencies (e.g. @staging, @production). You then specify which environment you want when initializing GetIt. This is really intuitive!

  2. Scope: Scope is less easy to manage with GetIt. It's all manual so you would need to remember to reset your scopes when required according to events taking place in your app. This is easy to mess up and difficult to test.

  3. Lifecycles: You do get some flexibility when it comes to lifecycles with GetIt/Injectable. You can choose whether dependencies are created fresh each time they're requested (a factory), or whether the same instance is passed on each request (a singleton). You can also choose whether dependencies are initialized lazily or not. You also have some control over when singletons are invalidated and need to be recreated. But the documentation around this isn't very comprehensive.

Overall, I'm not a huge fan of this approach for one reason: having global access to any dependency feels like a bit too much power to put in the hands of your team! It's far too easy to break conventions around the hierarchy of dependencies when you can just pull in whatever dependency you like from anywhere in your app. And while I'm not against code generation in principle, this doesn't feel like a problem that needs to be solved with more code generation.

Lots of other packages exist which are extremely similar to GetIt/Injectable, including Kiwi, Injector, Scope, and Stark. These are all different variations on the same idea of using a "container" to store and provide dependencies from outside the widget tree, and they all have the same advantages and drawbacks.

Packages we haven't talked about

  • Riverpod, which is not a DI framework but is often mistaken for one.
  • Bloc. Again, not a DI framework but does include Provider-style dependency injection features. You can use Bloc without using Provider, but I think it works best with the Provider-style approach.

Which approach is better?

Personally, having worked with GetIt/Injectable for a very long time and experimented with Provider more recently, I'm leaning very strongly towards the Provider-style approach to dependency management. It feels much more Flutter-native, and it's much more transparent in terms of how scopes and lifecycles are managed. That said, I can see it getting tedious when a project gets really big, and Injectable's code generation would certainly solve the complexity problem for larger projects.

What do you think? Are there any frameworks or approaches I've missed? Do you agree that Provider is generally the better approach for Flutter apps?

Did you find this article valuable?

Support Peter Bryant by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this