ZIO ZLayer Construction and a Stripped Service Pattern

(, en)

Similar to Scala, ZIO suffers from »there’s more than one way to do it« (TMTOWTDI). Although this motto is associated with Perl, ZIO and Scala share this characteristic.

ZIO offers various ways to accomplish tasks, but the knowledge is scattered, sometimes outdated and lacks a common standard. When creating a ZIO application that goes beyond a simple “toy app” to explore ZIO’s capabilities, the »The Five Elements of Service Pattern« tutorial comes closest to production code. This pattern gives a basic guideline, but is verbose and makes your code more abstract than needed.

Additionally, everything around ZEnvironment and ZLayer contains unneeded complexity. The lack of a standard and real world examples amplify the complexity. This is also an opportunity, of course: I can make up my own standard.

Here are my insights and guidelines I use when writing new ZIO applications:

  1. I tend not to use ZEnvironment-related concepts
  2. I restrict myself to ZLayer.fromFunction and ZLayer.make to construct and combine layers
  3. The Service Pattern (see below) is nice, but it can be streamlined
  4. Your app can work fine without ZLayer constructs

I’ll start with the example from ZIO’s »The Five Elements of Service Pattern« and try to shave off some code so that it becomes a »stripped service pattern«.

Service Definition

Let’s start with the boilerplate: the service definition

import zio.*
import zio.ZIO.ServiceWithZIOPartiallyApplied

case class Doc(
    title: String,
    description: String,
    language: String,
    format: String,
    content: String
)

trait DocRepo {
  def get(id: String): ZIO[Any, Throwable, Doc]

  def save(document: Doc): ZIO[Any, Throwable, String]

  def delete(id: String): ZIO[Any, Throwable, Unit]

  def findByTitle(title: String): ZIO[Any, Throwable, List[Doc]]
}

trait BlobStorage

trait MetadataRepo

trait DocService:
  def register(doc: Doc): ZIO[Any, Nothing, Unit]

class DocServiceImpl(docRepo: DocRepo, storage: BlobStorage, metadataRepo: MetadataRepo) extends DocService {
  override def register(doc: Doc): ZIO[Any, Nothing, Unit] = ???
}

In Scala land it is common to have a trait for everything, however, I’m not convinced that always a trait is needed (and it seems that I’m not the only one). Mostly because I need one additional »click« in my IDE for every jump from usage to implementation. As guideline I tend to use:

If it is a service, I consider stripping the trait

Often I have only one of its kind, meaning that I have one UserService or one ArticleService. Services should, if in the realm of Domain Driven Design (DDD), capture behavior (see also Fowler’s description of Service Layer), but not necessarily state.

This means (under optimal conditions):

So no need to split UserService into UserService and UserServiceImpl.

If it is a Repository, I create a trait & implementation class

class PgsqlDockRepo(xa: Transactor[Task]) extends DocRepo {
  override def get(id: String): ZIO[Any, Throwable, Doc] = ???

  override def save(document: Doc): ZIO[Any, Throwable, String] = ???

  override def delete(id: String): ZIO[Any, Throwable, Unit] = ???

  override def findByTitle(title: String): ZIO[Any, Throwable, List[Doc]] = ???
}

Splitting repository in trait and implementation has an underlying concept from DDD: I’m quite relaxed on this split. The database system and the interaction with it are often so intertwined with the business logic, that this distinction doesn’t have any practical benefit.

Additionally, in tests you might want to mock the repository (DocRepo in this case). Having a trait makes it easy to create a mock by implementing the exposed functions (even though I still prefer to use a mocking lib).

There are many developers advocating »you shouldn’t use mocks«, but to this day I haven’t seen any real world project without (the need for) mocks.

In terms of mocking libraries, I tend to use Mockito or ScalaMock. I’m very careful with Scala dependencies, often the bus factor is 1 due to the small size of the community. Therefore Mockito is my first choice: It is written in Java, has a big community and is very stable. It also has the side-effect that you have to write your mocks in imperative style. Annoying for functional programmers, leading to less mocks in total.

Repositories take a Transactor

If you use Doobie and are into tagless-final-style, you can do this

trait DocRepo[F[_]] {
...
}

class PgsqlDocRepo[F[_]](xa: Transactor[F]) extends DocRepo {
...
}

but I usually make it more concrete by using ZIO’s Task or ConnectionIO (because in your app codebase you don’t need this flexibility)

trait DocRepo {
...
}

class PgsqlDocRepo(xa: Transactor[Task]) extends DocRepo {
...
}

Just remember: stay consistent! That makes the life of your co-workers much easier.

Strip the Convenience Methods

As outlined in ZIO’s »The Five Elements of Service Pattern«, you can create accessor methods. Given that the goal is to make your API more ergonomic, it is quite a lot of boilerplate. To recap, the idea is to create accessor methods for all methods of the service:

object DocRepo {
  def get(id: String): ZIO[DocRepo, Throwable, Doc] =
    ZIO.serviceWithZIO[DocRepo](_.get(id))
  // ...
}

I’m not so sure about this. My doubt stems from the idea I have of Scala: a language that is so powerful that stays out of your way and lets you focus on implementing business logic.

However, with ZIO (and all other effect systems) you get a big bag of boilerplate constructs. You can hide it, but the distance from usage to implementation grows. An example path for a service pattern accessor call:

  1. usage of the service method
  2. convenience accessor method
  3. method definition in trait
  4. the implementation

That adds to the cognitive load by

over-engineering your code with too many abstractions

as indicated in the article Write Clean Code to Reduce Cognitive Load by the Google Testing Blog. Also refered to »Extraneous cognitive load« in Felienne Hermans’ book the Programmer’s Brain

Often accessor methods are not needed, because a service is constructed with unwrapped services (see DocService constructor). If you still need to access service methods from »outside«, a helper function that abbreviates ZIO.serviceWithZIO() could be interesting:

// Option 1
def zswz[S] = new ServiceWithZIOPartiallyApplied[S]
// Option 2
extension (zio: ZIO.type)
  def swz[S]: ServiceWithZIOPartiallyApplied[S] = new ServiceWithZIOPartiallyApplied[S]

// Usage
def doSomething = for {
  result1 <- ZIO.swz[DocRepo](_.get("123"))
  result2 <- zswz[DocRepo](_.get("123"))
} yield (result1, result2)

Use Terse Dependencies & ZLayer Construction

Let’s continue with DocService. As said earlier, it is up to you if you want to split it into service trait and service implementation class. In this toy example I split the DocService into service and implementation.

object BlobStorage {
  lazy val layer: ZLayer[Any, Nothing, BlobStorage] = ???
}

object MetadataRepo {
  lazy val layer: ZLayer[Any, Nothing, MetadataRepo] = ???
}

object PgsqlDockRepo {
  lazy val layer: ZLayer[Any, Nothing, PgsqlDockRepo] = ???
}

object DocServiceImpl {
  // For services with very few constructor args, you can use ZLayer.fromFunction
  val layer1: ZLayer[DocRepo & BlobStorage & MetadataRepo, Nothing, DocService] =
    ZLayer.fromFunction(new DocServiceImpl(_, _, _))

  // If you have many dependencies, you might be better of with ZLayer.apply
  val layer2: ZLayer[MetadataRepo & BlobStorage & DocRepo, Nothing, DocService] = ZLayer {
    for {
      docRepo <- ZIO.service[DocRepo]
      storage <- ZIO.service[BlobStorage]
      metadataRepo <- ZIO.service[MetadataRepo]
    } yield new DocServiceImpl(docRepo, storage, metadataRepo)
  }
}

I do the split to clarify that the layer attribute should be attached to the companion object of the concrete implementation class. The reasoning is as follows: depending on the implementation, the layer dependencies can be quite different. For example, a repository class for postgres vs one for cassandra. (see also a Github issue comment from Adam Fraser)

Alternatively you can also skip ZLayer construction completely by using manual dependency injection via constructors (aka constructor injection).

object Components {
  lazy val config: Config = ???
  lazy val xa: Transactor[Task] = createTransactor(config)
  lazy val blobStorage: BlobStorage = new BlobStorageImpl()
  lazy val metadataRepo: MetadataRepo = new MetadataRepoImpl()
  lazy val docRepo: DocRepo = new PgsqlDockRepo(xa)
  lazy val docService: DocService = new DocServiceImpl(docRepo, blobStorage, metadataRepo)
}

It is easier to digest and, assuming that your app will not work when one of your services cannot be initiated, I don’t see the added benefit of having everything dipped into effect-system-sauce.

Building the App

Now you can wrap everything up and create your ZIOApp:

object MainApp1 extends ZIOAppDefault {
  lazy val layers = ZLayer.make[DocService](
    BlobStorage.layer,
    MetadataRepo.layer,
    PgsqlDockRepo.layer,
    DocServiceImpl.layer1
  )

  def myApp: ZIO[DocService, Nothing, Unit] =
    for {
      docService <- ZIO.service[DocService]
      _ <- docService.register(Doc("title", "description", "language", "format", "content"))
    } yield ()

  override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = myApp.provideLayer(layers)
}

Or when you use manual dependency injection:

object MainApp2 extends ZIOAppDefault {

  import org.bargsten.zio.Components.docService

  def myApp: ZIO[Any, Nothing, Unit] =
    for {
      _ <- docService.register(Doc("title", "description", "language", "format", "content"))
    } yield ()

  override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = myApp
}

I have a slight preference for constructor injection. In my experience it is much easier to debug and understand. I’ve already wasted hours debugging ZIO layer issues, especially in tests.

The golden rule is: the more code you have, the higher the probability for bugs

See also