Accessor Methods (deprecated)
Accessor methods are little helper methods that lookup a service from the environment, and then forward your call to that service.
The service pattern provides a better way to structure programs, and it does not need accessor methods. Therefore, accessor methods are now deprecated.
What is an Accessor Method?​
Imagine a service defined as:
import zio._
trait BlobStorage {
def get(id: String): ZIO[Any, Throwable, Array[Byte]]
def put(content: Array[Byte]): ZIO[Any, Throwable, String]
}
The accessor methods are then defined as:
import zio._
object BlobStorage {
// Accessor method for BlobStorage.get
def get(id: String): ZIO[BlobStorage, Throwable, Array[Byte]] =
ZIO.serviceWithZIO[BlobStorage](_.get(id))
// Accessor method for BlobStorage.put
def put(content: Array[Byte]): ZIO[BlobStorage, Throwable, String] =
ZIO.serviceWithZIO[BlobStorage](_.put(content))
}
Each accessor method fetches the service from the environment, and then immediately forwards the method call.
The service can now be used as:
BlobStorage.get("blob-id") // returns a ZIO[BlobStorage, Throwable, Array[Byte]]
Notice how the BlobStorage
trait is in the environment (the R
channel) of the returned ZIO.
Why are accessor methods deprecated?​
Accessor methods have some drawbacks:
- You must write more code.
- The extra code must stay in sync with the service's trait.
- The service is looked up in the environment each time it is used. This incurs (a small) performance penalty.
- The ZIO environment permeates deeper into your code than strictly necessary. This problem is exacerbated when
services start exposing the services they depend on in the
R
channel of their method's return types.
The recommended service pattern injects service dependencies directly, and therefore has none of these problems.
Generating Accessor Methods with Macros​
Accessor Methods macros are only available for Scala versions 2.x
. They will not be made available for scala 3.x
.
Writing accessor methods is a repetitive task and is tedious for services with many methods. We can automate the
generation of accessor methods using the zio-macro
module.
To install the zio-macro
add the following line in the build.sbt
file:
libraryDependencies += "dev.zio" %% "zio-macros" % "<zio-version>"
In addition, enable macro expansion with:
-
for Scala
2.13
add the compiler option:scalacOptions += "-Ymacro-annotations"
-
for Scala
< 2.13
add the macro paradise compiler plugin:compilerPlugin(("org.scalamacros" % "paradise" % "2.1.1") cross CrossVersion.full)
If you are using IntelliJ, macro generated accessors will not be available in IDE hints without ZIO plugin.
Monomorphic Services​
We can use the @accessible
macro to generate service member accessors:
import zio._
import zio.macros.accessible
@accessible
trait ServiceA {
def method(input: Something): UIO[Unit]
}
// below will be autogenerated
object ServiceA {
def method(input: Something) =
ZIO.serviceWithZIO[ServiceA](_.method(input))
}
For normal values, a ZIO
with Nothing
on error channel is generated:
import zio._
import zio.macros.accessible
@accessible
trait ServiceB {
def pureMethod(input: Something): SomethingElse
}
// below will be autogenerated
object ServiceB {
def pureMethod(input: Something): ZIO[ServiceB, Nothing, SomethingElse] =
ZIO.serviceWith[ServiceB](_.pureMethod(input))
}
The @throwing
annotation will mark impure methods. Using this annotation will request ZIO to push the error on the
error channel:
import zio._
import zio.macros.accessible
import zio.macros.throwing
@accessible
trait ServiceC {
@throwing
def impureMethod(input: Something): SomethingElse
}
// below will be autogenerated
object ServiceC {
def impureMethod(input: Something): ZIO[ServiceC, Throwable, SomethingElse] =
ZIO.serviceWithZIO[ServiceC](s => ZIO(s.impureMethod(input)))
}
Below is a fully working example:
import zio._
import zio.macros.accessible
@accessible
trait KeyValueStore {
def set(key: String, value: Int): Task[Int]
def get(key: String): Task[Int]
}
case class InmemoryKeyValueStore(map: Ref[Map[String, Int]])
extends KeyValueStore {
override def set(key: String, value: Int): Task[Int] =
map.update(_.updated(key, value)).map(_ => value)
override def get(key: String): Task[Int] =
map.get.map(_.get(key)).someOrFailException
}
object InmemoryKeyValueStore {
val layer: ULayer[KeyValueStore] =
ZLayer {
for {
map <- Ref.make(Map[String, Int]())
} yield InmemoryKeyValueStore(map)
}
}
object MainApp extends ZIOAppDefault {
val myApp =
for {
_ <- KeyValueStore.set("key", 5)
key <- KeyValueStore.get("key")
} yield key
def run = myApp.provide(InmemoryKeyValueStore.layer).debug
}
Writing Polymorphic Services​
With Proper Type Parameters​
If the service is polymorphic for some proper types, we can use the @accessible
macro like previous examples.
Assume we have a KeyValueStore
like below, as we will see using @accessible
will generate us the accessor methods:
import zio._
import zio.macros.accessible
@accessible
trait KeyValueStore[K, V] {
def set(key: K, value: V): Task[V]
def get(key: K): Task[V]
}
case class InmemoryKeyValueStore(map: Ref[Map[String, Int]])
extends KeyValueStore[String, Int] {
override def set(key: String, value: Int): Task[Int] =
map.update(_.updated(key, value)).map(_ => value)
override def get(key: String): Task[Int] =
map.get.map(_.get(key)).someOrFailException
}
object InmemoryKeyValueStore {
val layer: ULayer[KeyValueStore[String, Int]] =
ZLayer {
for {
map <- Ref.make(Map[String, Int]())
} yield InmemoryKeyValueStore(map)
}
}
object MainApp extends ZIOAppDefault {
val myApp =
for {
_ <- KeyValueStore.set("key", 5)
key <- KeyValueStore.get[String, Int]("key")
} yield key
def run = myApp.provide(InmemoryKeyValueStore.layer).debug
}
With Higher-Kinded Type Parameters (F[_]
)​
If a service has a higher-kinded type parameter like F[_]
we should use the accessibleM
macro. Here is an example of
such a service:
import zio._
import zio.macros.accessibleM
@accessibleM[Task]
trait KeyValueStore[K, V, F[_]] {
def set(key: K, value: V): F[V]
def get(key: K): F[V]
}
case class InmemoryKeyValueStore(map: Ref[Map[String, Int]])
extends KeyValueStore[String, Int, Task] {
override def set(key: String, value: Int): Task[Int] =
map.update(_.updated(key, value)).map(_ => value)
override def get(key: String): Task[Int] =
map.get.map(_.get(key)).someOrFailException
}
object InmemoryKeyValueStore {
val layer: ULayer[KeyValueStore[String, Int, Task]] =
ZLayer {
for {
map <- Ref.make(Map[String, Int]())
} yield InmemoryKeyValueStore(map)
}
}
object MainApp extends ZIOAppDefault {
val myApp =
for {
key <- KeyValueStore.set[String, Int]("key", 5)
_ <- KeyValueStore.get[String, Int]("key")
} yield key
def run = myApp.provide(InmemoryKeyValueStore.layer).debug
}
With Higher-Kinded Type Parameters (F[_, _]
)​
If the service has a higher-kinded type parameter like F[_, _]
we should use the accessibleMM
macro. Let's see an
example:
import zio._
import zio.macros.accessibleMM
@accessibleMM[IO]
trait KeyValueStore[K, V, E, F[_, _]] {
def set(key: K, value: V): F[E, V]
def get(key: K): F[E, V]
}
case class InmemoryKeyValueStore(map: Ref[Map[String, Int]])
extends KeyValueStore[String, Int, String, IO] {
override def set(key: String, value: Int): IO[String, Int] =
map.update(_.updated(key, value)).map(_ => value)
override def get(key: String): IO[String, Int] =
map.get.map(_.get(key)).someOrFail(s"key not found: $key")
}
object InmemoryKeyValueStore {
val layer: ULayer[KeyValueStore[String, Int, String, IO]] =
ZLayer {
for {
map <- Ref.make(Map[String, Int]())
} yield InmemoryKeyValueStore(map)
}
}
object MainApp extends ZIOAppDefault {
val myApp =
for {
_ <- KeyValueStore.set[String, Int, String]("key", 5)
key <- KeyValueStore.get[String, Int, String]("key")
} yield key
def run = myApp.provide(InmemoryKeyValueStore.layer).debug
}