Thursday, August 13, 2020

Dependency Injection with ZIO

In a previous post, I showed how to set up a test in ZIO-land. This is more a how it works post.

If you recall, we created a ZIO that needs layers to unit test it. To expand, we have a ZIO that looks like this:

ZIO[Init with Flow with Errors, Throwable, ProblemList]

where InitFlow and Errors are my bespoke layers and ProblemList is just a type alise for a List of Eithers.

We add these layers later with provideLayer with implementations that are for testing or production. The interesting code has already been called, that is the construction of the ZIO in the first place. For me, it looks like:

  def flow(paths:       Filenames,
           configFile:  String,
           session:     SparkSession,
           fs:          FileSystem): ZIO[Init with Flow with Errors, Throwable, ProblemList] = for {
      s         <- Init.init(configFile, session, fs)
      results   <- Flow.resultsFor(s, paths, session, fs)

...

Now, taking the first element in the for comprehension, we see it looks like:

    def init(configFile:  String,
             session:     SparkSession,
             fs:          FileSystem): ZIO[Init, Throwable, Settings]
      = ZIO.accessM(_.get.initializeWith(configFile, session, fs))

This is where it becomes interesting. 

Although ZIO.accessM looks like we're calling a function, this is syntactic sugar. We're actually receiving a ZIO.AccessMPartiallyApplied[R] that "Effectfully accesses the environment of the effect" [docs].

The _.get returns the service we created (test or production) and initializeWith(...) is just calling my code. But how does get do its magic? Well, Init is just a type alias to Has[A] and "The trait Has[A] is used with ZIO environment to express an effect's dependency on a service of type A" [docs]. How it magics this into existence is down to the Izumi reflect library, whose mysteries I'm only just understanding. 

You can apply any number of layers and leave others dangling. For instance, if we have a:

RIO[KeyVaultLayer with Blocking, A]

(where RIO[R, A] is just an alias for ZIO[R, Throwable, A]) and we add layers so:

val kvService:  ULayer[KeyVaultLayer]               = ...
val zio:        RIO[KeyVaultLayer with Blocking, A] = ...
val partialZio: RIO[Blocking, A]                    = zio.provideSomeLayer[Blocking](kvService)

then you'll notice that partialZio still has one of the original requirements.

Although the ZLayer.fromFunction method seems to allow dependencies between layers, I was scratching my head on how to create a dependency on something that isn't a layer (in my case, a configuration that has been read within the ZIO I want the layer added to). I worked around it by having my layer provide a factory rather than the service itself.  

The take away point of all this is that you can defer adding a dependency until after the for-comprehension code (but before you execute it). This is very convenient for testing.


No comments:

Post a Comment