Dependency Injection in Dart using zones

Philipp S
4 min readAug 21, 2019

--

Zones in Dart are a neat language feature that offer an elegant solution to quite a few problems. In this post I’ll be looking at how they can be utilized to implement a dead simple dependency injection API.

Recap: dependency injection

If you’re unfamiliar with the concept of dependency injection, here’s a brief overview. The problem DI is solving is one of decoupling: For example, if I’m writing a database abstraction class, it needs to know the database credentials. It also needs a logger to log errors and debug messages.

Where do _logger and _config come from? Option A: PersistentStore instantiates them itself, but that is a bad practice because it violates the single responsibility principle. Option B: The objects are passed (aka injected) into the PersistentStore from outside.

And that is dependency injection in its simplest form.

However, this simple approach doesn’t work well in larger projects. The config files could get parsed in the main() function. But the PersistentStore might get instantiated by UserRepository, which gets instantiated by Authenticator, and so on. Now we must pass our DatabaseConfig through all the intermediary classes.

A more convenient API

Let’s come up with a simpler API. The first step is to define tokens that identify the values we want to inject:

Next, the piece of code that creates the values can provide them to the remaining application:

Inside of the callback passed to provide(), the token is now associated with the provided value, and can be read with a call to inject().

How it works …

The interesting part here is that inject() is able to access values passed to provide(). To get this API working without zones, we have to use a global variable.

provide() stores the provided values in that variable, calls the callback, and restores the previous configuration afterwards.

inject() searches for the requested token in that same global variable.

The reason why _provided is a List<Map> and not just a flat Map is that it allows us to restore the previous injector configuration after a call to provide() has returned. Iterating over _provided in reverse order ensures we will always use the value of the most recent provide() call for any given token. In other words, this program behaves as you would expect:

… until it doesn’t

Unfortunately, the implementation given above uses a global variable, and it also serves as a good demonstration why global variables are often frowned upon. That’s because it only works fine with synchronous code, but breaks as soon as we start calling asynchronous functions inside of the provide() callback. Without changes to provide(), this program will throw a MissingDependencyException:

Because the callback function passed to provide() gets blocked by an async operation, the inject() call will execute in a later event loop iteration — after provide() has already returned and cleaned up the _provided stack. When inject() is finally called, the value for loggerToken has already been removed.

Now, we could modify provide() to await the callback function before cleaning up the value stack:

This fixes the MissingDependencyException of the previous program, but causes another bug in the next one.

This program will print Dart twice. Why? Because the two asynchronous threads share the same global variable, and the second provide() call is executed before the first print().

This problem can only be solved by using a separate variable for each thread. That’s why many dependency injection packages don’t use top-level functions, but rather an Injector class that is configured with (token, value) pairs and from which the values can be read. If different parts of a program need different values injected, multiple Injector objects can be instantiated. The drawback is that now every class and function has to pass the injector down to it’s children.

Zones to the rescue

In Dart, however, we have another option: zones. Every thread runs in a zone. The default zone is Zone.root, and new zones can be created with Zone.fork() and runZoned(). Zones can be used for a variety of other useful tasks, but the interesting part for this use case are zone values. Zone values are defined when a zone is created, can’t be changed afterwards, and can be read through operator[] . It looks like this:

Huh, doesn’t that look familiar …

There are two notable differences between zone values and the global variable we used earlier. First, each zone has it’s own zone values, so we can spawn two concurrent threads in different zones and pass different values for the same keys to each. Second, since each callback function runs in its own zone, we don’t need to clean up anything — garbage collection does that for us.

As an additional bonus, if a zone doesn’t contain a key, it is automatically looked up in the zones parent (the zone that created this zone), and its parent, and so on. That means zone[key] already provides the nested lookup behaviour we had to implement manually by iterating over _provided.reversed.

So, our implementation of provide() and inject() can be reduced to:

Conclusion

Zones are a great tool if you need more control over the environment or error handling behaviour of certain functions or threads. If you haven’t already, check out the zone API docs and see what else you can use them for.

If you like my dependency injection API above, you can use package:zone_di from pub. Besides what I’ve covered here, that package also contains a provideFactories() function that creates token values from the given callback functions, which can inject() other tokens from the same provide call.

Thanks for reading, and happy coding!

--

--