ZIO environment meets constructor-based dependency injection

Adam Warski
SoftwareMill Tech Blog
9 min readJan 6, 2020

--

Update December 2022: the article below captures an early attempt to understand how ZIO environment might be best used. For an updated version, see ZIO environment: episode 3 and Structuring ZIO 2 applications.

ZIO is a library for asynchronous & concurrent programming in Scala. At its core, it’s built around the ZIO datatype, which describes side-effecting computations. The library is also at the center of an emerging ecosystem, which contains libraries for streaming, testing, actors, configuration, kafka and others.

The ZIO datatype, which is used to capture side-effects and describe concurrent/asynchronous processes, itself is built upon previous work on the IO monad in Haskell, the Task datatype in Scalaz, and the IO/Task datatypes in cats-effect/monix. However, it brings two important extensions: typed errors and environments.

That’s why a value describing a side-effecting process has three (!) type parameters. Having a ZIO[R, E, A], R specifies the requirement on the environment; E the type of failures which might occur when evaluating the computation; and A is the type of value produced when the computation successfully completes.

Let’s focus on the first type parameter: the environment.

Is ZIO environment a functional solution to dependency injection? When should it be used? How does it compare with other dependency injection approaches in Scala?

Environment overview

The environment consists of a number of dependencies, each wrapped in a module-trait which allow composition. For example, an effect which uses both the Clock and Console dependencies, would have the type:

val myProcess: ZIO[Clock with Console, ..., ...] = ...

where the modules (these two happen to ship with ZIO) are defined as follows:

trait Clock {
val clock: Clock.Service[Any]
}
object Clock {
trait Service[R] {
def currentDateTime: ZIO[R, Nothing, OffsetDateTime]
...
}
trait Live extends SchedulerLive with Clock {
val clock: Service[Any] = new Service[Any] {
def currentDateTime: ZIO[Any, Nothing, OffsetDateTime] = ...
...
}
}
object Live extends Live
}

The trait wrapper (Clock) is needed so that multiple dependencies, with independently-defined implementations, can be composed into a single type and value. As the R parameter is contravariant, multiple ZIOs with different requirements can be combined, and the appropriate combined environment type will be automatically inferred by the compiler. If you’ve heard about the cake pattern, this might sound familiar.

Individual instances of the dependencies can be accessed through the ZIO.access(f: R => A): ZIO[R, Nothing, A] method, which produces a ZIO value with a requirement on the accessed environment value. Finally, the requirements on the environment can be eliminated by providing instances of all of the dependencies using the ZIO.provide method.

Again, if you’ve heard about the Reader Monad, this might sound similar. Indeed, ZIO is a trifunctor, which horizontally combines a Monad (sequential composition), ApplicativeError (typed errors) and Reader (environment).

Composing these features horizontally — by providing a single data type with all of the composed functionalities — yields significant performance, readability and usability improvements. You can read more about both: the motivation behind ZIO environment and horizontal monad composition on John de Goes’s blog.

What’s a dependency?

But what’s a dependency, anyway? This is not a precisely defined term. As used here, a dependency is a type which instances are static, global and stateless:

  • Static: as it’s created on application startup, possibly depending on configuration, but not on dynamically-provided data.
  • Global: there’s usually a single instance of a given dependency in an application. You might think of this as the “singleton” scope.
  • Stateless: dependencies don’t usually carry any state (especially mutable state!), and are a collection of functions/methods implementing some business logic.

Note that global is different from global state or global availability. We want to avoid any global state, or values that can be arbitrarily accessed, as this would render all of our efforts to reasonably manage dependencies useless.

In other words, dependencies are service-like objects. This is opposed to data-like objects, which in contrast are dynamic, local and stateful. One exception here is configuration, which is usually static, global and stateful.

Summing up, we can roughly categorise the objects in our applications into three categories, out of which the first two can be treated as dependencies for other service-like objects:

  1. service-like objects: static, global, stateless
  2. configuration: static, global, stateful
  3. data: dynamic, local, stateful

ZIO environment and DI

As argued above, ZIO environment is similar to a combination of the reader monad with the cake pattern; the combination is a mix resulting in original features, but also inheriting some of the problems of both these techniques.

Can you say that ZIO environment is a form of dependency injection? Yes and no. As with the notion of dependency, also Dependency Injection (DI) is not a precisely defined term. Yes —ZIO environment is a form of managing dependencies in your project. No — it’s significantly different from other forms of dependency injection.

Some time ago I was trying to understand the principal difference between reader-monad-based DI and constructor-based DI. It turns out, that these two approaches aren’t alternatives, but they complement each other.

The crucial difference is what the caller knows about the dependencies of a called module.

With the reader-monad or ZIO environment, the dependencies of the module are explicit; the caller knows about them, and needs to provide them or propagate the requirement further.

With the constructor approach, the dependencies of a module instance are hidden (provided already when the module is constructed). This fundamental difference: either making dependencies explicit or keeping them hidden, is what differentiates the two approaches, but also makes it possible to use both for different use-cases.

When to use explicit dependencies

Explicit dependencies are great for detailed effect tracking. Given a value of type ZIO[R, E, A], we not only know that this is a description of a process with some side-effects; we know that these side effects are a result of using dependencies in the environment R. Hence, ZIO environment works great when we want to know exactly what kind of effects our asynchronous process description might use.

In other words, I’d use the ZIO environment for low-level, effectful dependencies, such as:

  • Database pool (e.g. Doobie’s Transactor, Slick’s Database)
  • HTTP client (e.g. sttp’s backend)
  • Email service
  • Blockchain node client
  • Kafka consumer/producer

Let’s call these integration dependencies.

When to use hidden dependencies

However, using the ZIO environment for everything could be an overkill. In fact, that’s one of the main problems of the cake pattern. Taking the idea of combining multiple dependencies using trait-modules into one huge object leads to “monster” type signatures, which are long and cryptic, with even longer and more cryptic compiler errors. Where to draw the line?

Any non-trivial application consists of multiple modules/services, which are divided among multiple classes so that concerns are properly encapsulated and the code readable and manageable. For example, we might have a UserRegistration class, which depends onUserNotifier (which in turn sends an email), and a UserModel class (which accesses the database).

We don’t want to clutter the type signature of the UserRegistration.register method with information that it uses the UserNotifier or UserModel. These are implementation details which don’t provide anything useful to the caller. They are implementation details, which should be hidden.

Creating a new dependency — factoring out a part of the bussines logic to a new class — should be as lightweight as possible. Our goal should be to create small, possibly self-contained, logical units of code. These units will be inter-dependent — and that’s fine. But the users of our module shouldn’t have to care about these inter-dependencies.

And that’s what constructor-based dependency injection is for. It’s creating the static, stateless graph of service objects, where each service is parametrised by its dependencies (see also What Is Dependency Injection?)

Summing up the use-case, I’d use constructor-based DI to wire together service-like classes, which don’t directly provide integration with the outside world.

Let’s call these business logic dependencies.

Combining the two

In the example above, while UserNotifier is an implementation detail, the fact that an email or the database is accessed as a side-effect might provide useful information to the user: it’s good to know how the code that you call interacts with the outside world.

Hence, combining these two approaches, we might end up with something like:

class UserNotifier {
def notify(
u: User,
message: String): ZIO[Email, EmailError, Unit] = ...
}
class UserModel {
def insertUser(u: User): ZIO[Database, DatabaseError, Unit] = ...
}
class UserRegistration(notifier: UserNotifier, model: UserModel) {
def register(
id: UserId,
details: UserDetails): ZIO[Email with Database,
AppError,
String] = {
val u = User(id, details)
for {
_ <- model.insertUser(u)
_ <- notifier.notify(u, "Registered")
} yield "User registered"
}
}

In the example above, some of the dependencies are explicit (Email, Database), some of them are hidden (UserNotifier, UserModel), depending on their role in the application.

Testing

Both of the approaches allow for various variants of testing.

First of all, we have unit tests. When testing a single class or method in isolation, we can provide mocked/stubbed/dummy dependencies, either via the constructor, or using ZIO.provide.

When doing semi-integration or integration testing, we can create full or partial object graphs of the service objects wired using constructors. ZIO environment dependencies are propagated bottom-up by construction. Again, we can provide either fake dependencies (e.g. for the Email effect), or real dependencies using embedded or dockerized, local services (such as an embedded database or a locally running blockchain node).

In integration tests, most likely we won’t have the need to swap out service objects wired using constructors. Although that’s feasible as well; see the di-in-scala guide for one approach. However, as these dependencies don’t provide direct integration with the outside world, just “business logic”, we shouldn’t need to replace them with fake versions.

Other usages of ZIO environment

ZIO environment can also have another promising usage: providing contextual data and scoping resources.

Take, for example, a library which allows working with relational databases. Any operations that should be run inside a transaction could require a Connection object to be in scope (an open connection, taken from the connection pool, serving the transaction).

Existing libraries have dedicated datatypes for this, e.g. ConnectionIO from Doobie or DBIOAction from Slick. In ZIO, we might be able to do away with a dedicated datatype and simply use ZIO[Connection, E, A].

That is, a value of type ZIO[Connection, E, A] describes a computation which needs to be run in scope of a transaction. These values could be combined as any other processes.

Eliminating the dependency: a function transact(pool): ZIO[Connection, E, A] => ZIO[Any, E, A] would return a computation which: takes a connection from the pool, begins the transaction and finally commits or rollbacks.

Using the terminology from the “What’s a dependency section”, here we would be using the environment for data-like objects, which from their nature are dynamic, local and stateful.

However, there’s one caveat: it’s currently not possible to eliminate a single dependency. We might eliminate all of the dependencies using ZIO.provide, but not a single one. Hence we might make the above work if the database-related processes where allowed only to use Connection, without e.g. requiring Clock or other explicitly passed dependencies as well.

Maybe Scala 3 will help in lifting this limitation?

Summing up

ZIO environment — a combination of the reader monad and the cake pattern — can live side-by-side with constructor-based dependency injection. The fundamental difference between the two is that in the former case, the dependencies are explicit and visible to the caller. While in the latter approach, the dependencies are hidden from the caller, and are an implementation detail.

ZIO environment can be used for:

  • dependencies, which should be made explicit to the caller
  • low-level, effectful dependencies, which directly integrate with the outside world, such as a database connection pool, email service integration or a message broker interface.
  • (potentially in the future) properly scoping resources and managing contextual data, such as open database connections. However, to work efficiently, this would require the ability to eliminate a single dependency

Modules wired using constructors are best for:

  • dependencies, which should be hidden from the caller
  • business logic, encapsulated into manageable, readable, functional pieces. This business logic might use the lower-level dependencies from the ZIO environment to implement their functionality.
  • configuration

Using the two-fold approach described above, we get the best of both worlds:

  • compile-time safety: correct usage of dependencies wired via constructors, and provided using the environment is checked at compile time
  • readability and IDE-friendliness: it’s straightforward to understand what are the dependencies of a class that are at our disposal, accessible either via class fields or the environment
  • performance and approachability of the fused ZIO monad; no overhead of monad transformers, an easy-to-comprehend API to describe asynchronous processes
  • explicit tracking of dependencies, which are sources of side-effects
  • testability on the unit and integration levels by providing fake implementations of dependencies, either via the environment or via the constructors
  • manageable and comprehensible types; the number of dependencies — corresponding to the number of different effects in our application — is constrained, so the types are relatively short, and the compiler errors meaningful
  • constraining the number of dependencies that need to be defined using the trait-service wrapper, reducing the ceremony needed to define a dependency

Dependencies come in different forms: some of them provide integration with the outside world, some implement business logic. There’s no need to use a single mechanism for all use cases, instead picking the best tool for the job at hand.

--

--

Software engineer, Functional Programming and Scala enthusiast, SoftwareMill co-founder