ZIO/ZLayer FAQ: How do I create a very simple ZLayer with ZIO 2?

ZIO FAQ: How do I create a very simple ZLayer in a ZIO 2 application?

Solution

As a wee bit of background, the ZIO Zlayer approach provides several important purposes, including:

  • Dependency injection
  • Modularity and composability
  • Resource management
  • Testability
  • Separation of concerns
  • Type safety

At its most basic, the ZIO 2 ZLayer gives you a way to provide configuration information to your application, similar to dependency injection approaches with other languages and tools.

A simple ZLayer example

For instance, imagine that your application requires configuration information such as (a) SQLite database configuration information and (b) an email address. One way to start solving this problem is to define a Scala case class for each piece of configuration information:

// case classes for our properties
case class EmailConfig(address: String)
case class SQLiteConfig(url: String)

After that, create ZLayer values for these two pieces of information:

val sqliteLive: ULayer[SQLiteConfig] = 
    ZLayer.succeed(SQLiteConfig("jdbc:sqlite:./stocks.sqlite"))

val emailLive: ULayer[EmailConfig] = 
    ZLayer.succeed(EmailConfig("coyote@acme.com"))

(Note that I create these as val values, but they can also be functions, as you’ll see later in this article.)

Then all you need to do is to “provide” your ZLayer values to your application. Here’s a small but complete ZIO 2 application named app that uses these two pieces of configuration information that are provided to it in the run value:

object ZioZLayerSimplest101 extends ZIOAppDefault:

    val app: ZIO[EmailConfig & SQLiteConfig, Throwable, Unit] = for
        sqLiteConfig <- ZIO.service[SQLiteConfig]       // receive this value
        emailConfig  <- ZIO.service[EmailConfig]        // receive this one, too
        _            <- Console.printLine(s"SQLite URL: ${sqLiteConfig.url}")
        _            <- Console.printLine(s"EMAIL:      ${emailConfig.address}")
    yield
        ()

    // this is where the layers are “provided” to the application
    // with the `ZIO::provide` method:
    val run =
        app.provide(sqliteLive ++ emailLive)
           .exitCode

The complete application

Here’s all that same information in one complete application:

package _zlayer_101

import zio.*

// case classes for our properties
case class EmailConfig(address: String)
case class SQLiteConfig(url: String)   // "jdbc:sqlite:./stocks.sqlite"

// the configuration values our app needs
val sqliteLive: ULayer[SQLiteConfig] = 
    ZLayer.succeed(SQLiteConfig("jdbc:sqlite:./stocks.sqlite"))

val emailLive: ULayer[EmailConfig] = 
    ZLayer.succeed(EmailConfig("coyote@acme.com"))

/**
 * https://zio.dev/reference/contextual/zlayer
 */
object ZioZLayerSimplest101 extends ZIOAppDefault:

    val app: ZIO[EmailConfig & SQLiteConfig, Throwable, Unit] = for
        sqLiteConfig <- ZIO.service[SQLiteConfig]
        emailConfig  <- ZIO.service[EmailConfig]
        _            <- Console.printLine(s"SQLite URL: ${sqLiteConfig.url}")
        _            <- Console.printLine(s"EMAIL:      ${emailConfig.address}")
    yield
        ()

    val run =
        app.provide(sqliteLive ++ emailLive)
           .exitCode

In this application, notice how the sqliteLive and emailLive configuration values are “provided” to the app application from the run value. Here I assume that you know how ZIO works and only want to know about ZLayer, but a key point to know is that every ZIO application starts with the run value (where run can be a value or a function).

Another approach that’s closer to the real world

Next, here’s a very similar example that’s a little closer to what you’ll be doing in the real world. In this application I don’t define the actual ZLayer values within the application, and instead define them in the run value:

package _zlayer_101

import zio.*

// case classes for our properties
case class EmailConfig(address: String)
case class SQLiteConfig(url: String)   // "jdbc:sqlite:./stocks.sqlite"

def sqliteLive(url: String): ULayer[SQLiteConfig] =
    ZLayer.succeed(SQLiteConfig(url))

def emailLive(address: String): ULayer[EmailConfig] =
    ZLayer.succeed(EmailConfig(address))

/**
 * https://zio.dev/reference/contextual/zlayer
 */
object ZioZLayerSimplest101 extends ZIOAppDefault:

    val app: ZIO[EmailConfig & SQLiteConfig, Throwable, Unit] = for
        sqLiteConfig <- ZIO.service[SQLiteConfig]
        emailConfig  <- ZIO.service[EmailConfig]
        _            <- Console.printLine(s"SQLite URL: ${sqLiteConfig.url}")
        _            <- Console.printLine(s"EMAIL:      ${emailConfig.address}")
    yield
        ()

    // i now provide the data for the ZLayer values here,
    // rather than in the variables inside the application
    val run =
        app.provide(
            sqliteLive("jdbc:sqlite:./stocks.sqlite") ++ emailLive("coyote@acme.com") 
        ).exitCode

This still isn’t a complete “real world” application, because (a) you’ll typically handle the ZLayer values a little differently inside your application, and (b) you’ll typically get your configuration information from configuration files or from your runtime environment, but this example does take you on the next step of your ZLayer journey.

For information on how to use configuration files with ZLayer, see my other tutorials:

In summary, I hope these ZIO 2 and ZLayer tutorials are helpful!