ptrbrynt

Thoughts on software engineering and tech leadership by Peter Bryant.

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?


Liked this? Buy me a coffee.

Find me elsewhere online here.

I'm a big fan of the Bloc library for Flutter apps, and when coupled with the freezed package for creating immutable data classes and sealed unions, I think this pattern really shines.

Sealed whats?

Let's start with some definitions. In other languages (like Kotlin), there are patterns available such as union types, sealed classes, and pattern-matching.

These are all slightly different expressions of the same idea: that you can create a class with a fixed set of subclasses, linking together multiple otherwise separate types under one “umbrella” class.

Shapes are a good example of how this could be used. Here's an example in Kotlin:

sealed class Shape

data class Square(val sideLength: Int): Shape()

data class Rectangle(val length: Int, val width: Int): Shape()

data class Circle(radius: Int): Shape()

Here we are defining 3 classes – Square, Rectangle, and Circle. Each has its distinct properties, but we're able to have them all extend the Shape superclass.

More importantly, because Shape is a sealed class, no other subtypes can be defined outside this file; in other words, we can restrict the subtypes of Shape to the ones we define.

So why would this be helpful when using the Bloc pattern?

Let's consider a simple Counter bloc. It would have two Events: CounterIncremented and CounterDecremented. In pure Dart, we would have to do something like this:

abstract class CounterEvent {}

class CounterIncremented extends CounterEvent {
  CounterIncremented(this.incrementBy);
  
  final int incrementBy;
}

class CounterDecremented extends CounterEvent {
  CounterDecremented(this.decrementBy);
  
  final int decrementBy;
}

This is fine, but there are a couple of noteworthy issues:

  • For Blocs with lots of events, you can see how this approach would become very verbose and cumbersome.
  • The above snippet doesn't take equality operators into account; that's more boilerplate we'd need to add.
  • When we eventually implement this Bloc, our code will be full of type-casting and type-checking, which is easy to make a mess with.

If we were writing Kotlin, we could solve these problems by implementing CounterEvent as a sealed class with each event type as a data class extending the base class.

sealed class CounterEvent

data class CounterIncremented(val incrementBy: Int) : CounterEvent()

data class CounterDecremented(val decrementBy: Int) : CounterEvent()

Kotlin's data class construct gives us immutability and equality without boilerplate.

But we're not writing Kotlin, and Dart doesn't have support for sealed classes out-of-the-box. We need to find an alternative!

Freezed Events

The freezed package gives us a couple of important abilities: we can create immutable data classes, and we can create sealed unions. Perfect!

Here's what our Counter events would look like using a freezed union:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'counter_event.freezed.dart';

@freezed
class CounterEvent with _$CounterEvent {
	const factory CounterEvent.incremented(int incrementBy) = CounterIncremented;
	const factory CounterEvent.decremented(int decrementBy) = CounterDecremented;
}

With this implementation, freezed has given us two immutable event types with equality operators. It even generates bonus stuff like copyWith and toString methods. This is a totally battle-ready implementation of our CounterEvent type.

When it comes to actually using instances of CounterEvent, freezed also gives us access to some pattern-matching syntax. Here's how we could implement our event handler:

event.when(
	incremented: (incrementBy) => emit(state + incrementBy),
	decremented: (decrementBy) => emit(state - decrementBy),
);

This syntax is helpful for a couple of reasons. First, forces us to account for every possibility; in pure Dart with type-casting, we could easily forget about or ignore certain event types. Second, it's super easy to read and understand!

Freezed States

Let's look at something more complex: writing a state type using freezed. Let's say we have some data to load: we'll probably want an initial state, an error state, and a success state. We also need a loading indicator to show the user when the data is being refreshed.

Using freezed, we can implement a state class like this:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'data_state.freezed.dart';

@freezed
class DataState with _$DataState {
	const factory DataState.initial(bool isLoading) = DataInitial;
	const factory DataState.error(String message, bool isLoading) = DataError;
	const factory DataState.success(Data data, bool isLoading) = DataSuccess;
}

Then, we can implement a BlocBuilder like this:

BlocBuilder<DataBloc, DataState>(
	builder: (context, state) {
		return Column(
			children: [
				if (state.isLoading) _buildProgressIndicator(),
				state.when(
					initial: (_) => _buildInitial(),
					error: (message, _) => _buildError(message),
					success: (data, _) => _buildSuccess(data),
				),
			],
		);
	},
);

There are a couple of things to notice here:

  • Because every state type has an isLoading property, we can access it without knowing what subtype of state we have. Thanks, freezed!
  • Again, the pattern-matching syntax provided by freezed is extremely useful here in giving us some super-readable code which forces us to handle all possible states.

Is this the perfect solution?

No.

There is no perfect solution! It could very easily be argued that freezed classes are still pretty verbose, and some developers prefer to not rely on code generation in their apps. And some folks just don't like Bloc at all for some reason. That's all fine – do what works for you!

But, having worked on more than 10 new Flutter projects in the past year or so, I've found that this pattern allows us to build and iterate quickly on our code, and improves my team's ability to understand one another's work easily.

As with lots of software engineering patterns and practices, there's no single right way to do things; the most important thing you can do is to pick one pattern and stick to it consistently.


Liked this? Buy me a coffee.

Find me elsewhere online here.

Enter your email to subscribe to updates.