ZIO ZLayer Construction and a Stripped Service Pattern
Update 13.4.2025 - The »Five Elements of Service Pattern« has become The Four Elements of Service Pattern and Accessor Methods were deprecated, so you can skip the the »Strip the Convenience Methods« section.
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:
- I tend not to use
ZEnvironment-related concepts - I restrict myself to
ZLayer.fromFunctionandZLayer.maketo construct and combine layers - The Service Pattern (see below) is nice, but it can be streamlined
- Your app can work fine without
ZLayerconstructs
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):
- I have one service of its kind
- I probably don’t have to mock this service, because it doesn’t have state This statement might be controversial. I usually try to stick to TDD, Where Did It All Go Wrong by Ian Cooper. I try to test behaviour and not implementation details.
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.
- the repository trait is part of the domain logic
- the repository implementation is not part of the domain logic
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:
- usage of the service method
- convenience accessor method
- method definition in trait
- 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
