In a previous post I’ve shown how to use ZIO environments to provide your program with dependencies, or modules. While using environments at the customer I’m currently working for, we found out that the logic to get a database session object using a module would run over and again. This makes sense, since a ZIO[R, E, A] is a prescribed way of getting an A, and the result is not cached. Our application was reading configuration files and creating SQL sessions on every module call, while the resulting object was obviously constructed from the same underlying values. There are multiple ways to solve this:

  • Creating the singleton objects before running you application logic.

  • Caching the result of the loading code in a reference.

In this post I’ve chosen the latter, because I wanted to show the use of ZIO’s Ref. Also, I like how semantically the desired data and the logic of retrieving it belong together.

Ref to the rescue!

Going back to a simple application that has a ConfigurationModule (like here), when we ask the module for the configuration class a few times, it will load the configuration from the classpath again and again. I added some console logs to show what is happening here.

Listing 1. our main application that uses the ConfigurationModule
import zio._
import zio.clock.Clock
import zio.console.Console

object Main extends App {
  def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = program

  val logic: ZIO[Console with ConfigurationModule, Nothing, Int] = (for {
    _      <- console.putStrLn(s"I'm running!")
    config <- ConfigurationModule.factory.configuration
    _      <- console.putStrLn(s"This is the ${config.appName} application")
    config <- ConfigurationModule.factory.configuration
    _      <- console.putStrLn(s"Again, this is the ${config.appName} application")
    config <- ConfigurationModule.factory.configuration
    _      <- console.putStrLn(s"And again, this is still the ${config.appName} application")
  } yield 0)
    .catchAll(e => console.putStrLn(s"Application run failed $e").as(1))

  private val program =
    logic.provideSome[Console with Clock] { c =>
      new Clock with Console with ConfigurationModule.Live {
        override val clock: Clock.Service[Any]     = c.clock
        override val console: Console.Service[Any] = c.console
      }
    }
}
Listing 2. the Live part of the ConfigurationModule containing the logic. N.B. the heaviness is encoded in the time delay.
trait Live extends ConfigurationModule {
    val console: Console.Service[Any]

    val configurationModule: ConfigurationModule.Service[Any] = new Service[Any] {
      override def configuration: Task[Configuration] = loadHeavyConfigFromFile
    }

    private def loadHeavyConfigFromFile =
      for {
        (duration, config) <- ZIO
          .fromEither(ConfigSource.default.load[Configuration])
          .mapError(e => ConfigurationError(e.toList.mkString(", ")))
          .delay(500.milliseconds)
          .timed
          .provide(Clock.Live)
        _ <- console.putStrLn(s"Loaded configuration from file. That took ${duration.toMillis} ms")
      } yield config
  }

When running Main, it prints:

Listing 3. console logs
I'm running!
Loaded configuration from file. That took 841 ms
This is the zio-environment-example application
Loaded configuration from file. That took 505 ms
Again, this is the zio-environment-example application
Loaded configuration from file. That took 502 ms
And again, this is still the zio-environment-example application

Process finished with exit code 0

It would be nice to cache the value that is loaded, so we only need to load it once. ZIO has a great way of achieving this in a functional way: Ref. Ref is a wrapper around an AtomicReference, returns pure values (wrapped in a ZIO[Any, Noting, A]) and is completely thread-safe. We only need the basic methods on it: get and update.

Let’s add Ref to our module:

Listing 4. the Live part of the ConfigurationModule containing the logic
trait Live extends ConfigurationModule {
val clock: Clock.Service[Any]
val console: Console.Service[Any]
val configRef: Ref[Option[Configuration]]

    val configurationModule: ConfigurationModule.Service[Any] = new Service[Any] {
      override def configuration: Task[Configuration] = useConfigFromRef.orElse(loadHeavyConfigFromFile)
    }

    private def useConfigFromRef: IO[ConfigurationError, Configuration] =
      for {
        configOption <- configRef.get
        config       <- ZIO.fromOption(configOption).mapError(_ => ConfigurationError("The config isn't loaded"))
        _            <- console.putStrLn("The configuration was already loaded. So convenient!")
      } yield config

    private def loadHeavyConfigFromFile: IO[ConfigurationError, Configuration] =
      for {
        (duration, config) <- ZIO
          .fromEither(ConfigSource.default.load[Configuration])
          .mapError(e => ConfigurationError(e.toList.mkString(", ")))
          .delay(500.milliseconds)
          .timed
          .provide(Clock.Live)
        _ <- console.putStrLn(s"Loaded configuration from file. That took ${duration.toMillis} ms")
        _ <- configRef.update(_ => Some(config))
      } yield config
  }

There. On initialisation the Ref will contains a None, since we did not yet load the configuration. In the loadHeavyConfigFromFile method, the Ref is updated with the value of the just created config. Next time the ConfigurationModule is called, the Ref contains the config and don’t need to wait for the heavy loading method.

Because creating a Ref returns a ZIO, we need to wrap it in a for-comprehension in Main:

Listing 5. the provision part of Main
private val program = for {
    ref <- Ref.make[Option[Configuration]](None) //initialize the Ref with None here
    logic <- logic.provideSome[Console with Clock] { c =>
      new Clock with Console with ConfigurationModule.Live {
        override val clock: Clock.Service[Any]             = c.clock
        override val console: Console.Service[Any]         = c.console
        override val configRef: Ref[Option[Configuration]] = ref
      }
    }
  } yield logic

When we run our application again, the heavy method runs only once:

I'm running!
Loaded configuration from file. That took 849 ms
This is the zio-environment-example application
The configuration was already loaded. So convenient!
Again, this is the zio-environment-example application
The configuration was already loaded. So convenient!
And again, this is still the zio-environment-example application

Process finished with exit code 0

Enjoy using Ref in your own applications!

The full source code can be found on GitLab.

shadow-left