Skip to main content
Version: 2.0.x

Providing Different Implementation of a Service

One of the benefits of using dependency injection is that, we can write our application in a way that without modifying the application logic, we can provide different implementations of services to our application.

Example 1: Config Service

In the next example, we have a ZIO application that uses the AppConfig service:

import zio._

case class AppConfig(poolSize: Int)

object AppConfig {
def poolSize: ZIO[AppConfig, Nothing, Int] =
ZIO.serviceWith[AppConfig](_.poolSize)

val appArgsLayer: ZLayer[ZIOAppArgs, Nothing, AppConfig] =
ZLayer {
ZIOAppArgs.getArgs
.map(_.headOption.map(_.toInt).getOrElse(8))
.map(poolSize => AppConfig(poolSize))
}

val systemEnvLayer: ZLayer[Any, SecurityException, AppConfig] =
ZLayer.fromZIO(
System
.env("POOL_SIZE")
.map(_.headOption.map(_.toInt).getOrElse(8))
.map(poolSize => AppConfig(poolSize))
)
}

object MainApp extends ZIOAppDefault {
val myApp: ZIO[AppConfig, Nothing, Unit] =
for {
poolSize <- AppConfig.poolSize
_ <- ZIO.debug(s"Application started with $poolSize pool size.")
} yield ()

def run = myApp.provideSome[ZIOAppArgs](AppConfig.appArgsLayer)
}

The AppConfig has two layers, appArgsLayer and systemEnvLayer. The first one uses command-line arguments to create the AppConfig and the second one uses environment variables. As we can see, without changing the core logic of our application, we can easily change the way we get the configuration:

object MainApp extends ZIOAppDefault {
val myApp: ZIO[AppConfig, Nothing, Unit] =
for {
poolSize <- AppConfig.poolSize
_ <- ZIO.debug(s"Application started with $poolSize pool size.")
} yield ()

- def run = myApp.provideSome[ZIOAppArgs](AppConfig.appArgsLayer)
+ def run = myApp.provide(AppConfig.systemEnvLayer)
}

Example 2: Logging Service

In this example, we have a ZIO application that uses the Logging service. And we provided two implementations of the Logging service: SimpleLogger and DateTimeLogger:

import zio._

import java.io.IOException

trait Logging {
def log(msg: String): ZIO[Any, IOException, Unit]
}

object Logging {
def log(msg: String): ZIO[Logging, IOException, Unit] =
ZIO.serviceWithZIO[Logging](_.log(msg))
}

case class DateTimeLogger() extends Logging {
override def log(msg: String): ZIO[Any, IOException, Unit] =
for {
dt <- Clock.currentDateTime
_ <- Console.printLine(s"$dt: $msg")
} yield ()
}

object DateTimeLogger {
val live: ULayer[DateTimeLogger] =
ZLayer.succeed(DateTimeLogger())
}

case class SimpleLogger() extends Logging {
override def log(msg: String): ZIO[Any, IOException, Unit] =
Console.printLine(msg)
}
object SimpleLogger {
val live: ULayer[SimpleLogger] =
ZLayer.succeed(SimpleLogger())
}

Now, let's write a ZIO application that uses the Logging service:

import zio._

val myApp: ZIO[Logging, IOException, Unit] =
for {
_ <- Logging.log("Application started.")
_ <- Logging.log("Application ended.")
} yield ()

Now, we can run our application, just by providing one of the implementations of the Logging service. Let's run it with the SimpleLogger implementation:

object MainApp extends ZIOAppDefault {
def run = myApp.provide(SimpleLogger.live)
}

Now, we can see that, without changing the core logic of our application, we can easily change the logger implementation:

object MainApp extends ZIOAppDefault {
def run = myApp.provide(DateTimeLogger.live)
}