Skip to main content
Version: 2.0.x

The Five Elements of Service Pattern

Writing services in ZIO using the Service Pattern is very similar to the object-oriented way of defining services. We use scala traits to define services, classes to implement services, and constructors to define service dependencies. Finally, we lift the class constructor into the ZLayer.

Let's start learning this service pattern by writing a DocRepo service:

1. Service Definition

Traits are how we define services. A service could be all the stuff that is related to one concept with singular responsibility. We define the service definition with a trait named DocRepo:

import zio._

case class Doc(
title: String,
description: String,
language: String,
format: String,
content: Array[Byte]
)

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]]
}

2. Service Implementation

It is the same as what we did in an object-oriented fashion. We implement the service with the Scala class:

case class DocRepoImpl() 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]] = ???
}

3. Service Dependencies

We might need MetadataRepo and BlobStorage services to implement the DocRepo service. Here, we put its dependencies into its constructor. All the dependencies are just interfaces, not implementation. Just like what we did in object-oriented style.

First, we need to define the interfaces for MetadataRepo and BlobStorage services:

case class Metadata(
title: String,
description: String,
language: String,
format: String
)

trait MetadataRepo {
def get(id: String): ZIO[Any, Throwable, Metadata]

def put(id: String, metadata: Metadata): ZIO[Any, Throwable, Unit]

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

def findByTitle(title: String): ZIO[Any, Throwable, Map[String, Metadata]]
}

trait BlobStorage {
def get(id: String): ZIO[Any, Throwable, Array[Byte]]

def put(content: Array[Byte]): ZIO[Any, Throwable, String]

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

Now, we can implement the DocRepo service:

case class DocRepoImpl(
metadataRepo: MetadataRepo,
blobStorage: BlobStorage
) extends DocRepo {
override def get(id: String): ZIO[Any, Throwable, Doc] =
for {
metadata <- metadataRepo.get(id)
content <- blobStorage.get(id)
} yield Doc(
metadata.title,
metadata.description,
metadata.language,
metadata.format,
content
)

override def save(document: Doc): ZIO[Any, Throwable, String] =
for {
id <- blobStorage.put(document.content)
_ <- metadataRepo.put(
id,
Metadata(
document.title,
document.description,
document.language,
document.format
)
)
} yield id

override def delete(id: String): ZIO[Any, Throwable, Unit] =
for {
_ <- blobStorage.delete(id)
_ <- metadataRepo.delete(id)
} yield ()

override def findByTitle(title: String): ZIO[Any, Throwable, List[Doc]] =
for {
map <- metadataRepo.findByTitle(title)
content <- ZIO.foreach(map)((id, metadata) =>
for {
content <- blobStorage.get(id)
} yield id -> Doc(
metadata.title,
metadata.description,
metadata.language,
metadata.format,
content
)
)
} yield content.values.toList
}

4. ZLayer (Constructor)

Now, we create a companion object for DocRepoImpl data type and lift the service implementation into the ZLayer:

object DocRepoImpl {
val layer: ZLayer[BlobStorage with MetadataRepo, Nothing, DocRepo] =
ZLayer {
for {
metadataRepo <- ZIO.service[MetadataRepo]
blobStorage <- ZIO.service[BlobStorage]
} yield DocRepoImpl(metadataRepo, blobStorage)
}
}

5. Accessor Methods

Finally, to create the API more ergonomic, it's better to write accessor methods for all of our service methods using ZIO.serviceWithZIO constructor inside the companion object:

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

def save(document: Doc): ZIO[DocRepo, Throwable, String] =
ZIO.serviceWithZIO[DocRepo](_.save(document))

def delete(id: String): ZIO[DocRepo, Throwable, Unit] =
ZIO.serviceWithZIO[DocRepo](_.delete(id))

def findByTitle(title: String): ZIO[DocRepo, Throwable, List[Doc]] =
ZIO.serviceWithZIO[DocRepo](_.findByTitle(title))
}

Accessor methods allow us to utilize all the features inside the service through the ZIO Environment. That means, if we call DocRepo.get, we don't need to pull out the get function from the ZIO Environment. The ZIO.serviceWithZIO constructor helps us to access the environment and reduce the redundant operations, every time.

Similarly, we need to implement the BlobStorage and MetadataRepo services:

object InmemoryBlobStorage {
val layer =
ZLayer {
???
}
}

object InmemoryMetadataRepo {
val layer =
ZLayer {
???
}
}

This is how ZIO services are created. Let's use the DocRepo service in our application. We should provide DocRepo layer to be able to run the application:

import zio._
import java.io.IOException

object MainApp extends ZIOAppDefault {
val app =
for {
id <-
DocRepo.save(
Doc(
"title",
"description",
"en",
"text/plain",
"content".getBytes()
)
)
doc <- DocRepo.get(id)
_ <- Console.printLine(
s"""
|Downloaded the document with $id id:
| title: ${doc.title}
| description: ${doc.description}
| language: ${doc.language}
| format: ${doc.format}
|""".stripMargin
)
_ <- DocRepo.delete(id)
_ <- Console.printLine(s"Deleted the document with $id id")
} yield ()

def run =
app.provide(
DocRepoImpl.layer,
InmemoryBlobStorage.layer,
InmemoryMetadataRepo.layer
)
}

During writing the application, we don't care which implementation version of the BlobStorage and MetadataRepo services will be injected into our app. Later at the end of the day, it will be provided by one of ZIO#provide* methods.

That's it! Very simple! ZIO encourages us to follow some of the best practices in object-oriented programming. So it doesn't require us to throw away all our object-oriented knowledge.