Release Pager is a sample ZIO application that demostrates many features of ZIO. It is desccribed in three blogs:

1 Design

The application has serveral services:

  • The Chat Storage and Repository Version Storage store chat and repository version into a database.
  • The Subscription Logic handles subscriptions and uses the Chat Storage and Repository Version Storage to data persistency.
  • The GitHub Client uses Http Client to check versions of a list of GitHub repositories.
  • The Repository Validator uses the GitHub Client to validate versions.
  • The Telegram Client pages subscribed uses. It uses Subscription Logic and Repository Validator.
  • The Release Checker schedule the services. It uses Telegram Client, Subscription Logic and Github Client.

The source code is organized by technical layers that form a directed dependency graph: backend -> service -> storage -> domain.

  • domain has all the models.
  • storage has all the data persistency logic.
  • service has all business logics.
  • backend has the logic of starting the server.

The package names are named after business domains, not by techinical layers.

Actually, use business domains to organize soruce code, the technical layers are used only for shared code among doamins.

Another design decision is to use domain types, the the Scala data types. For example, instead of String, the Version and Name types are used for GitHub repository version and repository name.

1
2
final case class Version(value: String)
final case class Name(value: String)

Another design decision is to avlid monadic types such as Option[T] as method parameters. Using the simple data type T will make the implementation and testing much easier. Use two methods if a parameter is optional.

This is a good idea. Put more restrictions at the boundary of a system will make the internal much simpler.

2 Code

2.1 Module Pattern

Define a service interface in an object. The ZIO uses Pascal-casing naming for this object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@accessible
object SubscriptionLogic {
  type SubscriptionLogic = Has[Service]

  trait Service {
    def subscribe(chatId: ChatId, name: Name): Task[Unit]
    def unsubscribe(chatId: ChatId, name: Name): Task[Unit]
    ...
  }

  type LiveDeps = Logger with ChatStorage with RepositoryVersionStorage
  def live: URLayer[LiveDeps, Has[Service]] =
    ZLayer.fromServices[Logger.Service, ChatStorage.Service, RepositoryVersionStorage.Service, Service] {
      (logger, chatStorage, repositoryVersionStorage) =>
        Live(logger, chatStorage, repositoryVersionStorage)
    }
}

The interface methods should only use the inpput/output types. The dependencies are declared in the value of ZLayer that implements the service. The actual service implementation is defined in a case class in a separate file.

The @accessible annotation generates accessor methods in the service object like def method(...): ZIO[R, E, A] = ZIO.accessM(_.get.method(...)). For a service module that is only used in ZLayer.fromServices, there is no need to create accessor methods.

Use a meaningful name instead of Live.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private[subscription] final case class Live(
  logger: Logger.Service,
  chatStorage: ChatStorage.Service,
  repositoryVersionStorage: RepositoryVersionStorage.Service
) extends SubscriptionLogic.Service {
  override def subscribe(chatId: ChatId, name: Name): Task[Unit] =
    logger.info(s"$chatId subscribed to $name") *>
      chatStorage.subscribe(chatId, name) *>
      repositoryVersionStorage.addRepository(name)

  override def unsubscribe(chatId: ChatId, name: Name): Task[Unit] =
    logger.info(s"Chat $chatId unsubscribed from $name") *>
      chatStorage.unsubscribe(chatId, name)
  ...
}

2.2 Start

The application overrides the the zio.App#run method to run the program.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
override def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = {
  val program = for {
    token <- telegramBotToken orElse UIO.succeed("972654063:AAEOiS2tpJkrPNsIMLI7glUUvNCjxpJ_2T8")

    config <- readConfig
    _      <- FlywayMigration.migrate(config.releasePager.dbConfig)

    http4sClient <- makeHttpClient
    canoeClient  <- makeCanoeClient(token)
    transactor   <- makeTransactor(config.releasePager.dbConfig)

    _ <- makeProgram(http4sClient, canoeClient, transactor)
  } yield ()

  program.foldM(
    err => putStrLn(s"Execution failed with: ${err.getMessage}") *> ZIO.succeed(1),
    _ => ZIO.succeed(0)
  )
}

It first creates a token and config. Then it migrates database and creates three manages resources: http client, pager client and db transactor. These are used to wire up program dependencies.

ZIO.runtim[R].map { implicit rt => ...} is used to run code that uses resource R. The above code use the ZIO cats interop functions to create managed resources.

2.3 Wiring Up

The logger layer is the Lagger.console. The http client, pager client and db transctor layers are created by the managed resource’s toLayer.orDie method.

The following code creates the dependency layers for sbuscription logic:

1
2
3
4
val chatStorageLayer = transactorLayer >>> ChatStorage.doobie
val repositoryVersionStorageLayer = transactorLayer >>> RepositoryVersionStorage.doobie
val storageLayer = chatStorageLayer ++ repositoryVersionStorageLayer
val subscriptionLogicLayer = (loggerLayer ++ storageLayer) >>> SubscriptionLogic.live

Both vertical and parallel depdencies are composed in the heirarchy. Similarly, repository validator dependency layers are created.

For inMemory storarage, create the dependent layers using ZLayer.fromEffect as the following:

1
2
3
4
5
6
7
8
9
val versionMap = ZLayer.fromEffect(Ref.make(Map.empty[Name, Option[Version]]))
val subscriptionMap = ZLayer.fromEffect(Ref.make(Map.empty[ChatId, Set[Name]]))

val logger = Logger.console

val chatStorage = subscriptionMap >>> ChatStorage.inMemory
val repositoryVersionStorage = versionMap >>> RepositoryVersionStorage.inMemory
val storage = chatStorage ++ repositoryVersionStorage
val subscriptionLogic = (logger ++ storage) >>> SubscriptionLogic.live

The start method of the telegramp client and the repeat method of the release check return two effects. The program is described as startTelegramClient.fork *> scheduleRefresh and its dependencies are fulfilled by program.provideSomeLayer[ZEnv](programLayer).