zio layers and framework integration
https://github.com/zio/zio is a FP library for scala. When used to organize your entire program, your main
function inherits from the zio App
and you return a zio effect (ZIO
). You don’t need to worry about creating a default environment or other such details.
This blog touches on using zio in an existing framework and composes services using ZLayer
. When zio is inserted into an existing framework, the setup is different.
We will add zio to an existing javascript app stack with only a few lines of extra “glue” code–that’s how easy it is. If your app is not complex, you should stick with javascript/typescript and async/await or stick with composing js.Promise
in scala.js–they are good enough for a wide range of work. If you have something more complicated, read on.
Some useful ZLayer
blogs:
- https://timpigden.github.io/_pages/zlayer/Examples.html?utm_campaign=ZIO News&utm_medium=email&utm_source=Revue newsletter
- https://medium.com/@aadelegue/zio-zlayer-with-playframework-8e393574bea7
- Shows how to slice in zio and layers into a play framework. I like that. But I don’t know the play framework so I was a little lost.
- https://scala.monster/welcome-zio/
- Good intro on creating services in a standard way then layering
ZLayer
on top.
- Good intro on creating services in a standard way then layering
- https://www.slideshare.net/PierangeloCecchetto/ray-tracing-with-ziozlayer?utm_campaign=ZIO News&utm_medium=email&utm_source=Revue newsletter
- Good intro on zlayer construction approaches
- https://github.com/debasishg/frdomain-extras/tree/with-zio
- Super clean canonical example of services using some layers to compose
- https://github.com/etorreborre/fp-to-the-min/tree/simple-wiring/fpmin/shared/src/main/scala/fpmin
- Great reworked example so you can compare before and after zlayer
This blogs covers code using scala.js on nodejs, using apollo graphql and powering apollo graphql resolvers using ZIO effects. The approach here is not specific to scala.js. There is an implicit assumption that by taking this approach, you are slicing scala/scala.js and zio into an existing application stack rather than starting greenfield.
When using a js platform, zio does not help with parallelism, it helps with concurrency. You may consider zio a bit overkill in a js environment but once you start working on a non-trivial effectful application, you realize that a strong framework for managing effects is helpful.
You could code resolvers in typescript using async/await. But I’ve found that if I use js, I begin building js/ts effect management functions for sequencing and error handling. Given its javacript, you are always searching, daily, for a better library that handles effects composition–its an endless js search. I will, however, use non-zio scala.js/javascript/typescript for simple resolvers, e.g., return a single value from a remote service or calculate a simple number.
Even with a multitude of js/ts asynchronous frameworks such as bluebird promises or node-async, I thought I would use zio for complex resolvers since its comprehensive, aligned with scala’s type system and gives me options for improving performance that I don’t have with a pure js solution.
If composed correctly, you can replace the query layer with JVM based query logic and change a resolver to run on the jvm in graaljs. This gives you a performance boost that you can take advantage of incrementally in your code base without having to split out another microservice.
Problem at Hand
Our technical challenge:
- Integrate into apollo graphql asynchronous resolvers.
- Since we are using apollo, we are not using https://github.com/ghostdogpr/caliban. Think of this as more finely slicing into a brownfield application stack.
- Glue 2 SQL databases together without the benefit of distributed transactions, the API is asynchronous.
- JDBC is not available on a js platform so all of the JDBC-based frameworks are not a consideration.
- Add several other asynchronous, offplatform services that must also be accessed.
- Normally, even a small application needs several services.
- Not allowed to throw an error and hope that the apollo framework does the right thing. We must have precise error management.
- Effectful concerns such as logging, function call tracking, …a bunch of other concerns…
For this problem, the benefits of configuring zio, converting effects into ZIO
, and encountering some syntax overhead outweighs the costs. For simple problems, zio is overkill on a js platform and js with async/await is good enough.
The examples used in this blog are a bit like the ones used in the zio docs. But this blog shows details that are slightly more complex. However, they are not as complex as what is described in some of the blogs listed above.
I have a couple of other blogs on zio topics, but you can skip them. This blog is self-contained.
- https://appddeevvmeanderings.blogspot.com/2019/11/scalajs-zio-query-management-using-zio.html
- https://appddeevvmeanderings.blogspot.com/2019/12/http-server-nodejs-express-zio.html
Quick ZLayer Thoughts
ZLayer is a dependency management system that works with ZIO effects. If you are familiar with spring dependency injection, scala’s distage, or really any injection framework, then it may be familiar:
- ZLayer holds the instructions to create a set of interrelated services. ZLayer is like the spring XML config or classpath-scanning config processor.
- A ZLayer maps inputs into outputs so think of it as a function.
- A layer is a mapping from either an empty parent or a set of dependencies to another set of dependencies.
- You construct a service using the instructions in the layer. The layer pulls dependencies, if any, by type, from a parent layer and creates some outputs which are the outputs you need for your zio effect or inputs into another layer.
- The first/base layer does not have any dependencies. You can create the first layer using
ZLayer.succeed
or from a ZIO effect.succeed
lifts values into a layer and makes it available for dependency processing. - Create “layers” (horizontal integration via
++
) of related dependencies.- Compose two unrelated layers together using layers or combine them as dependencies before putting the dependency into a layer–your choice.
- Given an existing ZLayer, create “hierarchies” of “bean containers” where one layer depends on another layer (vertical integration via
>>>
). Typically, your initial layer has no input dependencies (hence is Any) and you>>>
into the next layer that requires those dependencies. If well designed, only the base layer changes. - Composing vertically or horizontally is like constructing a spring bean through XML or classpath scanning. Of course, ZLayer is about being explicit so it does not use either of those approaches. Similarly in spring, you can create layers of bean containers.
- Some of the constructors for creating a layer allows inputs as a plain objects. Others produce a
Has
dependency. It may get confusing to see bothobject MyService { trait Service { ... }}
andtype MyService = Has[MyService.Service]
.- The standard set of ZIO services are already wrapped in
Has
.
- The standard set of ZIO services are already wrapped in
- The first/base layer does not have any dependencies. You can create the first layer using
- Dependencies are objects contained in a
Has
type. TheHas
types allows you to access services via “get.” Think ofHas
as the bean container because given aHas
instance, you can access multiple services by their type.- A
Has
is also similar to a project dependency in SBT. In SBT, dependencies have a type although you rarely see the type explicitly. But whenever you type"org" %% "module" % latest.release
you are create aModule
instance. A build is dependent on several “modules.” - A dependency expressed as
Has[ServiceA]
orHas[ServiceA] with Has[ServiceB]
is really a pathway to a service.- To obtain a service from a module (say with one service in it), you call
theHasInstance.get
. - With multiple services in the same dependency declaration, you use
theHasValue.get[ServiceA]
ortheHasValue.get[ServiceB]
. - Dependencies can be combined horizontally using
++
similar to Layers which seems consistent. This is similar to combining spring bean containers.
- To obtain a service from a module (say with one service in it), you call
- To create a multi-service dependency you can create them directly using
Has
or throughZLayer
s. In fact, you use both. - Technically, the
Has
trait allows you to create a hierarchy that avoids conflicts between each service’s methods and values. A standard trait hierarchy likeServiceA with ServiceB with ServiceC
could have alot of conflicts. Ugh!- Yeah, this looks like structural typing.
- A
ZLayer is a compsition framework for dependencies.
Many people believe that you do not need service composition frameworks. If your problem is simple enough, that’s true.
You can think these types of frameworks as “fancy constructors” where “constructor functions” are a value and the constructors are called under the control of a processor that provides the arguments. Many new frameworks, including reactjs for web programming, separates out the “constructor function” from the args and combines the two under a controller.
Works for me!
ZLayer Types
There are many ZLayer “smart” constructors so you have many ways to start the topmost layer of your services stack. You need to decide what type of value you want to start with and what layers you need to satisfy different dependencies.
Assume we have a plain value config: Config
and we want a DBResource.Service
:
trait Config
val db1config: String
val db2config: String
}
type DBResource = Has[DBResource.Service]
object DBResource {
trait Service {
def db1(): UIO[String]
def db2(): UIO[String]
}
// for-comprehension accessors
def db1(): ZIO[DBResource, Nothing, String] = ZIO.accessM(_.get.db1())
def db2() = ZIO.accessM(_.get.db2()) // same type as db1()
}
The get
in the db1/db2
accessor suggests that we need a DBResource = Has[DBResource.Service]
value in the ZIO’s effect environment R
when the effect is run.
How do we construct a DBResource.Service
from a Config
?
Config
is a dependency that needs to be inserted into a base layer. First, let’s write a few recipes (think XML or classpath scanning) to create a DBResource
given a Config
or a Has[Config]
:
type HasConfig = Has[Config]
// no object Config with a Service inside, Config stands on its own...
// we can't really write Config = Has[Config] :-)
// DBResource
object DBResource {
// ...
// start with a plain object Config
val fromConfig1: ZLayer[Config, Nothing, DBResource] =
ZLayer.fromFunction { e: Config =>
new Service {
def db1() = ???
def db2() = ???
}
}
// Start with HasConfig
val fromConfig2: ZLayer[HasConfig, Nothing, DBResource] =
ZLayer.fromFunction { e: HasEnvironment =>
// must use e.get to access values in Environment
new Service {
def db1() = ???
def db2() = ???
}
}
// Start with HasConfig, same result as fromConfig2
// fromService takes plain objects in its thunk, unwrapped
val formConfig2: ZLayer[HasConfig, Nothing, DBResource] =
ZLayer.fromService[Environment, DBResource.Service] { e: Environment =>
new Service {
def db1() = ???
def db2() = ???
}
}
}
Although the input is different, all of these output a DBResource = Has[DBResource.Service]
.
- If we start with a service expressed as a plain value,
fromConfig1
works. - If we need to construct the resource from a
Has[Config]
“service locator”, we needfromConfig2
orfromConfig3
.
What’s our starting point?
Assume val c: Config = ???
. Let’s create the recipe that creates a DBResource
.
Plain Config
object starting point
val layerWithDbResource = ZLayer.succeedMany(c) >>> DBResource.fromConfig1
Layer.succeedMany
can take a raw object and produces a layer that does not wrap the raw object in a Has
. That’s exactly what fromConfig1
needs.
Start with a Config Has
dependency
// ZLayer.succeed produces a ZLayer[Any,Nothing,Has[Config]]
val layerWithDbResource = ZLayer.succeed(c) >>> DBResource.fromConfig2
val layerWithDbResource = ZLayer.succeed(c) >>> DBResource.fromConfig3
ZLayer.succeed
produces Has[Config]
which is what fromConfig2/3
is expecting.
This following won’t work because the first layer with ZLayer.succeed
returns Has[Config]
, not a plain Config
.
// won't work
val layerWithDbResourceWontCompile = ZLayer.succeed(c) >>> DbResource.fromConfig1
Start with a Big Layer with a Has
Config dependency in it
This scenario is more common. You have several services in a layer already and need to create the DBResource
. In this case, its best to make sure everything is a Has
. You can map into a layer to extract a plain obect value in case you need to adapt a layer that assumes a plain object as input.
type Dep1 = Has[Dep1.Service]
type Dep2 = Has[Dep2.Service]
type Dep3 = Has[Dep3.Service]
// something gives you a big layer
val biglayer: ZLayer[Any,Nothing, Dep1 with Dep2 with Dep3
with Has[Config]] = ???
// narrow down the big layer to the actual object which can be extracted
val layerWithDBResource = biglayer.map(_.get[Config]) >>>
DBResource.fromConfig1
// throw the biglayer at it and ZLayers plucks out the Has[Config]
val layerWithDBResource = biglayer >>> dbResource.fromConfig2
// or fromConfig3
Mini Moral
The moral of the story is to design your starting point. You may need 2-3 ways to create your service depending on what enviroment you support. There are some subtle and potentially confusing starting points for creating layers but you have flexibility.
It’s easy to get confused from the type names. A type like type DbResource = Has[DbResource]
describes the dependency in a layer but you also create an object DbResource
holding the service trait. Then you can create a layer from a plain object or a Has
object or even an layer that’s a result of evaluating an effect.
apollo graphql server resolvers
An apollo graphql server resolver has this type signature:
type Resolver[Parent, Args, Context, Info, Result] =
js.Function4[Parent, Args, Context, Info, js.Promise[Result]|Result]
// js.Thenable is a superclass of js.Promise
A resolver can return a simple value or a value wrapped in an effect. Generally, if the processing can take awhile you need to run asynchronously. The resolver is the “framework” that we need to tie into with zio.
If we want to use ZIO, we need to tie into the framework at the resolver level. If you use https://github.com/ghostdogpr/caliban you have a different stack of course and you are probably running on the jvm.
The Context parameter in the resolver allows you to inject dependencies into a resolver from the apollo js framework. The context object is constructed per request but you can fill it with application or session scoped values. You create the Context’s shape when you configure the base apollo server. Context
is injected into your resolver by the framework. You provide a “context function” to configure the Context given a Request
object from the raw HTTP web request. You return an object or an effect (with an object) containing your values. The context function is the recipe:
function async createApolloContext({req, ...rest}) {
return {
user: req.user,
cache: appWideCache,
// you can await value as needed
poolForDb1: await db1Pool,
...
}
}
If the domain complexity warrants it, js dependency injection or value initialization framework might be useful to create the js context function. Even with cleverly constructed micro-services that shrink the complexity of any given service, you may still have a big bowl of spaghetti.
For our resolver, the Context
value will be used to create the first/base ZLayer
.
Context
is like Config
used in the previous zio example. A context usually contains plain values but can also contain effectful values such as those related to db resources. In js world, there is no JDBC. Each DB driver is a bit different unless you use a db abstraction like https://sequelize.org/.
Each services constructor could depend on the entire context but we may have existing services that depend on fewer values that are derived from parts of the context. We need to “break up” the context into smaller parts so that they can be plucked out by our service constructors.
So instead of this depending on the entire Context
object like this:
type DbResource = Has[DbResource.Service]
object DbResource {
trait Service { ... }
val fromEverythingInContext =
ZLayer.fromFunction[Context, DbResource]{ bigConfig => ??? }
Perhaps just part of the context:
val fromSomethingInContext =
ZLayer.fromFunction[PartOfContext, DbResource] { part => ??? }
Or even, given that we can use Has
or plain objects:
val fromSomethingInContext =
Zlayer.fromFunction[Has[PartOfContext], DbResource] { haspart => ??? }
Let’s assume a context shape:
trait Context {
val user: User
val logger: Logger
}
We can break up a Context
into smaller services as follows:
def breakupContext(c: Context) =
ZLayer.succeedMany(Has(c.user) ++ Has[c.logger] ++ Has[c.moreStuff])
// or
def breakupContext(c: Context) =
ZLayer.succeed(c.user) ++ ZLayer.succeed(c.logger) ++
ZLayer.suceed(c.moreStuff)
// later in a Resolver
val baseLayer = breakupContext(context)
We should structure the breakup function specifically for the individual parts needed by the services.
Services
Let’s throw together a few services. We need a service for accessing data and a service for the database resources like DbResource
above.
Let’s assume its worth the effort to have a separate DbResource
because other modules may want access to these foundational database resources for other reasons. For example, a logging service that logs to a database instead of a log file needs database resources. We won’t show the logger service since its pretty standard.
But wait, if its js platform, everything is a js.Promise
!
To use zio, we need to convert js.Promise
s to a zio effect. zio has a bultin function for this but I want to preserve js specific error channels so I’ll write my own effects importer.
def jsPromiseToZIOJSError[E,T](p: js.Thenable[T], cvte: Any => E) =
IO.effectAsync[E, T]{ cb =>
p.`then`[Unit](
{ (t:T) => cb(IO.succeed(t))},
js.defined{ (e: scala.Any) =>
cb(IO.fail(cvte(e)))
}
)
}
// syntax support myJSPromise.toZIO[MSSQLError]
implicit class RichJSPromiseJSError[T](private val t: js.Thenable[T]) extends AnyVal {
def toZIOJSError = jsPromiseToZIOJSError[js.Error,T](t, _.asInstanceOf[js.Error])
def toZIO[E] = jsPromiseToZIOJSError[E,T](t, _.asInstanceOf[E])
}
In js land, a database connector may have dramatically different APIs unlike on the JVM where everything uses the JDBC API. js APIs and error channels are specific to the driver.
In the case of mssql
, a js driver for javascript, the errors are derived from MSSQLError
which are subclasses of js.Error
. I don’t want those values wrapped in js.JavaScriptException
that scala.js generates and I would expect errors to be mostly generated from js-based subsystems. We need to trap any scala Throwables
and make sure they are cleanly unwrapped, but we expect most errors to be js.Error
based. We wil not go into zio effects and error handling in this blog.
Let’s assume that our DbResource
provides 2 mssql
ConnectionPool
objects. You create a ConnectionPool
at the start of your program then use the pools to create transaction and “request” objects used to execute SQL queries or call stored procedures. The terminology and API for js-world database packages overlaps and conflicts with that used in JDBC. The terminology and APIS are different for each driver.
In the mssql
API, you can reuse request objects to run multiple SQL queries sequentially or use multiple request objects to run concurrent queries. The mssql
best practice is to use a different Request
objects and sequence the creation and running of queries yourself. Once you call Request.query("select ...")
the query is running as js.Promises
are eagerly evaluated just like scala Future
.
DbResource service (needed by the Repo service)
A DBResource
will provides 2 ConnectionPool
objects. The mssql
driver gives you a js.Promise[ConnectionPool]
from a configuration object. Generally, you do something like:
val pool = await theJsPromiseConnectionPool
val result = await pool.request().query("..")
It is possible for the effect that returns the connection pool to fail if the pool crashes or disconnects without autoconnect. This does happen so we need to be mindful of errors.
We could write an apollo context function that returns the result of the await
but let’s assume we have a js.Promise[ConnectionPool]
in our context:
// Apollo resolver context
@js.native
trait Context {
// environment is defined by our app and holds several other non-requset scoped objects
val environment: Environment
}
@js.native
trait Environment {
val db1: js.Proimse[ConnectionPool]
val db2: js.Promise[ConnectionPool]
// ...more environment objects
}
We want:
type DbResource = Has[DbResource.Service]
object DbResource {
trait Service {
val db1: IO[MSSQLError, ConnectionPool]
val db2: IO[MSSQLError, ConnectionPool]
// accesors in for-comprehensions, if we use the @accessible macro annotation
// we don't need to write these ourselves.
val db1: ZIO[DBResource, MSSQLError, ConnectionPool] = ZIO.accessM(_.get.db1)
val db2: ...
}
Instead of being dependent on the entire Context
from graphql, we’ll be dependent on part of the context: Has[Environment]
. We could chosen to be dependent only on Environment
(not wrapped in Has
) but its suggested to always wrap in Has
:
val fromEnvironment: ZLayer[Has[Environment], MSSQLError, Service] =
// or use fromServices and the Has wrapping is automatic
ZLayer.fromFunction { e: Has[Environment] =>
new Service {
val db1 = e.get.db1.toZIO[MSSQLError]
val db2 = e.get.db2.toZIO[MSSQLError]
}}
Repo
This repo is similar to that described in the zio docs.
import zio.macros._
@accessible
type Repo = Has[Repo.Service]
object Repo {
trait Service {
def getValue(arg: String): IO[js.Error, String]
}
// @accessible writes this for us
//def getValue(arg: String): ZIO[Repo, js.Error, String] = ZIO.accessM(_.get.getValue(arg))
}
Below, the implementation’s constructor requires non-effectful values. We expect our composition layer to handle the breakout and and the unwrapping of the effects. Of course, we could have written a different service implementation that takes effectful connection pools or values.
// ServiceImpl.scala
class ServiceImpl(
db1: ConnectionPool,
db2: ConnectionPool,
logging: Logging.Service,
user: User // plain user object
) extends Repo.Service {
// check db1 and then db2 for the value and return the average or the first one...
// use user service to get a user id, etc.
def getValue(arg: String) = ???
}
Given this definition, our layer recipe needs to take a DBResource
as input, evaluate the pool effects to unwrap them, then call the constructor.
To evaluate the pool effects in parallel use the effect <&> effect
combinator. When using an effect in a layer, the layer becomes dependent on the effect’s enviroment R
. We are lucky because in this case the effects are IO[MSSQLError, ConnectionPool]
. IO has a R=Any
. This means that our layer will pickup up an input dependency of R = Any
as well as any other Has[*]
types the service requires. In the definition below, we also need user and logging services. Note the M
on fromServices
indicating that the return value is an effect, not a plain value.
object ServiceImpl {
// arg order does not matter
def fromServices =
ZLayer.fromServicesM{(
d: DBResource.Service,
u: UserContext.Service,
l: Logging.Service) =>
(d.db1 <&> d.db2)
.map(poolstuple =>
ServiceImpl(poolstuple._1, poolstuple._2, l, u))
}
}
All Together
Let’s put zio into play.
Assume a couple of services for the example
trait User {
def userId(): UIO[String]
}
// elide details
trait Logging {
def info(msg: String): UIO[Unit]
}
// elide details
Slice into apollo graphql resolver
zio does not currently have the right error channel flexibility I want. We’ll just create our own promise in the resolver and run the zio effect in it. This gives us complete control.
We would normally separate out the code from the resolver and make the resolver a “trivial router” but it is all lumped together in the example below. When lumped into the resolver, it seems have alot of boilerplate but you can easily factor it out.
// We don't use these type but could be convenient for example in defining the services above
// instead of writing Has[Environment]
type HasEnvironment = Has[Environment]
type HasUser = Has[User]
type RunLayer = ZLayer[Any, js.Error, Repo with Logging with User]
import User._ // impl not shown
import Logging._ // impl not shown
import Repo._
val SomeGraphQLField: Resolver[js.Any, Args, Context, js.Any, Boolean] = (_, a, c, _) => {
// process raw args
val arg: String = a.arg
// Create layers, choose concrete implementations for basic services
// like logging, user and db resources. Flow through apollo context-derived services
// in case the next layer needs them.
val baseLayer = ZLayer.succeedMany(Has(c.environment) ++ Has(c.user)) >+>
(DBResource.fromEnvironment ++ Logging.fromSomethingInEnvironment)
// Choose concrete implementations for our Repo but let the upper level
// chooose implementations for Logging, User and DBResource. Only
// output 3 services.
val programLayer =
ZLayer.requires[Logging] ++ // Pass through from parent layer
ServiceImpl.fromServices ++ // Needs DBResource, User and Logging in the upper layer
ZLayer.requires[User] // Pass through from parent layer.
// the final layer to be used with the zio effect
val runLayer: RunLayer = baseLayer >> programLayer
// create zio program from zio effects
val program: ZIO[Repo with User with Logging, js.Error, Unit] = for {
userId <- userId()
_ <- info(s"processSomething for user $userId)
value <- getValue(arg)
_ <- info(s"Wow, what a value! $value")
} yield ()
// return js.Promise that completes with zio effect values
new js.Promise[FieldResult]({success, failure) =>
zio.Runtime.default.unsafeRunAsync(program.provideLayer(runLayer)) {
case Exit.Failure(cause) =>
// do something with cause...
// the js.Error is returned to the apollo machinery
val jserror = cause.failureOption getOrElse js.Error(cause.prettyPrint)
failure(jserror)
case Exit.Success(()) =>
success(true)
}
}
}
So:
Zlayer.succeedMany(Has(c.environment) ++ Has(c.user))
- Creates a base layer from the graphql resolver context. It can use any inputs (
Any
). Vertically composing this layer with others means that you your layers will not have any upstream inputs.
- Creates a base layer from the graphql resolver context. It can use any inputs (
>+> (DBResource.fromEnvironment ++ Logging.fromSomethingInEnvironment)
- Vertically composes but includes everything in the previous layer plus this layer’s DBResource and Logging.
ZLayer.requires[Logging] ++ ServiceImpl.fromServices ++ ZLayer.requires[User]
- Pass through
Logging
andUser
from the previous layer. - Make a specific choice for the Repo service,
ServiceImpl
.
- Pass through
baseLayer >>> programLayer
: Vertically compose thebaseLayer
and theprogramLayer
. The result layer only has the services thatRunLayer
indicates, 3 services total.
The layers are values and could be defined once elswhere. If different resolvers need the same dependencies for their effects, we could just pull them in directly. We were lucky in the sense that the context was “injected” by the apollo framework for us and it had services ready for use. If the services were expensive to create, we could bake them into a new zio environment and use that to run our effects.
ZLayer composition can be tricky because you need a mental model of a layer–what’s in and what’s out. For example, if you want a layer that includes all of the previous layer’s services, you can use:
val oldLayer = ???
val nextLayer = ???
val newLayer = oldLayer >+> nextLayer
newLayer
will have everything in oldLayer
and nextLayer
. If I want to define nextLayer
so that it always includes everything in the previous layer, a choice that a layer could make, then you could force nextLayer
to pass through all service vs letting the layer consumer decide:
val nextLayer = ZLayer.identity >+> ...
You can think of this similar to what you see in typescript types since this is really structural typing to some degree. “Map” objects are untyped structural typing if that even makes sense! In typescript, you can “pick”, “omit” and union (via a sum type) types at the “field” level. That’s essentially what we are doing here.
The zio types guide you in composing the layers but when you have alot of services the compiler type errors are strung across a couple of lines and can become hard to read. Fortunately, they are accurate and you can easily match up the list of services between “found” and “required” type signatures.
We can factor out the layers since they are just values. In some of my code I have something like:
// be specific on database resources, cache, user and logging
val baselineLayer = DBResource.layer ++ Cache.fromMemoryCache ++ UserContext.layer ++ Logging.layer
// be specific on the Repo
val programLayer = Repo.layer ++ ZLayer.requires[UserContext] ++ ZLayer.requires[Logging]
// in the resolver
val runLayer = breakUpContext(c) >+> baselineLayer >>> programLayer
Assuming common features are factored out, only a few extra lines of code are needed to tie zio to the resolver.
That’s not too bad. And, testing is easier.
Service Scope
The resolver above first obtains the resolver “argument” as a plain value and it does not bundle it into the effect. The ZIO effect accesses the arguments directly.
We could bake the arguments in as a dependency in the environment to make the “program” standalone. The layer would need to include the relevant resolver parameters. For example, if the args were captured by the type Args
, we would need to ensure that Args
are in the layer as a dependency. We have other ways to bake in per-request or per-run arguments so there are more ways than just using layers to handle this.
The environment can be defined with desired services/values at the application scope, or per invocation inside a resolver which is a request scope. You may need to add values in different scopes, much like apollo graphql Context
parameter is injected into every resolver request. To manage services scope with zio, you bake the values into the appropriate environment.
If we had added the following “service” to the base layer:
val lifetime = ZLayer.fromAcquireRelease(UIO(println("acquired")))(_ => UIO(println("released")))
We would see the println strings within the resolver as it runs the service is acquired then released. That may or may not be what we want depending on the service. For per-request args, that’s fine. But expensive resources, you need careful scope design.
Some thoughts on scope:
- Application scope: Map the environment on the default runtime or create your own through
ZLayer.unsafeFromLayer
. - Mid-level scope: Create a runtime via mapping or layers and use the value for specific areas of your application.
- Request scope: In our case, we created the entire stack of services inside the request. We could also add the user supplied parameters to the layers and then provide it to the effect to obtain an effect that relies on the default environment. As it is, the effect pulls in a resolver argument from outside its value definition.
- We only needed the default runtime services (Clock, Random, etc.) by the time our resolver layers added required service dependencies. But, we could have pushed the logger into the default runtime and use it across the entire application. If we did that, we could then remove it from resolver’s layer construction. However, all of our Repo definitions would need to be changed to
ZIO[standard env and the logger, E, A]
reflecting the need for a logger.
- We only needed the default runtime services (Clock, Random, etc.) by the time our resolver layers added required service dependencies. But, we could have pushed the logger into the default runtime and use it across the entire application. If we did that, we could then remove it from resolver’s layer construction. However, all of our Repo definitions would need to be changed to
You have choices.
You can use a ZLayer to create an environment object (ZLayer.unsafeFromLayer
or aLayer.toRuntime
). If the services are expensive to create, you will need to control the scope of their creation by controlling the “runtime” that you use to run your effect. It’s import to realize though, if some of your services are initailized as an effect, you will always need to “flatMap” into it at some point to use it on a js platform because you cannot await.
In our case, a mssql
ConnectionPool
is created external to the resolver. Connection pools are expensive to create and “connect.” We created our DBResource
service as a result of those expensive operations so our layer creation was cheap. When integrating into frameworks, we need to think about “scope” just like with any other backend.
Don’t forget the error channel
The error channel above is consistent in that we had Nothing
, js.Error
and MSSQLError
which inherits from js.Error
. So the services combined together fine. That’s not always the case. Our services may be highly specialized. We would need to map the error channel where its used or wrap the entire service.
For example, if we had a service like:
sealed trait Bad
case object BadValue extends Bad
case object BadProcessing extends Bad
trait CrazyService {
def doit(): IO[Info, String]
}
object CrazyService {
val live: ZLayer[Console.Service, Bad, CrazyService] =
ZLayer.fromService[Console.Service, CrazyService] { c =>
new CrazyService {
import c._
def doit() = putStrLn("CrazyService: boom") *> IO.fail(BadValue)
}
}
}
We would need to conform error values at use site or wrap the service directly.
Money in the bank
I made a mistake typing some of the examples.
Here’s an error message I received. It’s crazy helpful.
This error handling operation assumes your effect can fail.
However, your effect has Nothing for the error type, which
means it cannot fail, so there is no need to handle the
failure. To find out which method you can use instead of
this operation, please see the reference chart at:
https://zio.dev/docs/can_fail
Other error messages just as expressive.
Money in the bank.
Thanks zio team!
Comments
Post a Comment