ZIO Environment and Dependency Injection

The Problem with Dependencies

Consider a typical application:

class UserService(database: Database, logger: Logger) {
  def getUser(id: Int): User = {
    logger.info(s"Fetching user $id")
    database.findUser(id)
  }
}

// Creating the service is painful
val database = new PostgresDatabase(url, user, password)
val logger = new ConsoleLogger()
val userService = new UserService(database, logger)

Problems:

  • Manual wiring: You wire dependencies by hand
  • Testing is hard: Need to create real instances or complex mocks
  • Tight coupling: Services know concrete implementations
  • Order matters: Must create dependencies in the right order

How does ZIO solve this? Through the Environment parameter (R in ZIO[R, E, A]).

What is the ZIO Environment?

The R in ZIO[R, E, A] represents what this effect needs to run:

// Needs nothing
ZIO[Any, Error, User]

// Needs a Database
ZIO[Database, Error, User]

// Needs both Database and Logger
ZIO[Database & Logger, Error, User]

Think of it as dependency injection in the type system. The compiler ensures you provide everything an effect needs before it can run.

Understanding the R Parameter

Any - Needs Nothing

val simple: ZIO[Any, Nothing, Int] = ZIO.succeed(42)
// Can run anywhere, requires no environment

Single Dependency

trait Database {
  def findUser(id: Int): Task[User]
}

val effect: ZIO[Database, Throwable, User] = 
  ZIO.serviceWithZIO[Database](_.findUser(123))
// Requires a Database to run

Multiple Dependencies

val effect: ZIO[Database & Logger, Throwable, User] = for {
  _    <- ZIO.serviceWithZIO[Logger](_.info("Fetching user"))
  user <- ZIO.serviceWithZIO[Database](_.findUser(123))
} yield user
// Requires both Database and Logger

Accessing Services from the Environment

ZIO.service - Get the Service

trait Logger {
  def info(message: String): UIO[Unit]
}

// Get the entire service
val getLogger: URIO[Logger, Logger] = 
  ZIO.service[Logger]

// Use it
val program = for {
  logger <- ZIO.service[Logger]
  _      <- logger.info("Hello!")
} yield ()

ZIO.serviceWith - Use the Service Directly

// Better: use the service immediately
val program: URIO[Logger, Unit] = 
  ZIO.serviceWith[Logger](logger => logger.info("Hello!"))

ZIO.serviceWithZIO - Chain Effects

// Best: when the service method returns an effect
val program: URIO[Logger, Unit] = 
  ZIO.serviceWithZIO[Logger](_.info("Hello!"))

Which accessor would you use to log a message and continue with other operations?

Creating Services

Define a Service Trait

trait UserRepository {
  def getUser(id: Int): Task[User]
  def saveUser(user: User): Task[Unit]
  def listUsers(): Task[List[User]]
}

Implement the Service

case class UserRepositoryLive(database: Database) extends UserRepository {

  def getUser(id: Int): Task[User] = 
    ZIO.attempt {
      database.query(s"SELECT * FROM users WHERE id = $id")
    }

  def saveUser(user: User): Task[Unit] = 
    ZIO.attempt {
      database.execute(s"INSERT INTO users VALUES (${user.id}, '${user.name}')")
    }

  def listUsers(): Task[List[User]] = 
    ZIO.attempt {
      database.queryAll("SELECT * FROM users")
    }
}

Service Companion Object Pattern

object UserRepository {
  // Accessor methods for convenience
  def getUser(id: Int): ZIO[UserRepository, Throwable, User] =
    ZIO.serviceWithZIO[UserRepository](_.getUser(id))

  def saveUser(user: User): ZIO[UserRepository, Throwable, Unit] =
    ZIO.serviceWithZIO[UserRepository](_.saveUser(user))

  def listUsers(): ZIO[UserRepository, Throwable, List[User]] =
    ZIO.serviceWithZIO[UserRepository](_.listUsers())
}

Now you can use it elegantly:

val program = for {
  users <- UserRepository.listUsers()
  _     <- ZIO.foreach(users)(user => Console.printLine(user.name))
} yield ()

Introduction to ZLayer

ZLayer is how you construct and provide services to effects.

ZLayer[RIn, E, ROut]
  • RIn: What this layer needs to be built
  • E: How construction can fail
  • ROut: What service this layer provides

Think of ZLayer as a recipe for creating a service.

Creating Simple Layers

trait Logger {
  def info(message: String): UIO[Unit]
}

case class ConsoleLogger() extends Logger {
  def info(message: String): UIO[Unit] = 
    Console.printLine(message).orDie
}

// Create a layer that provides Logger
val loggerLayer: ZLayer[Any, Nothing, Logger] = 
  ZLayer.succeed(ConsoleLogger())

Layers with Dependencies

trait Database {
  def query(sql: String): Task[ResultSet]
}

trait UserRepository {
  def getUser(id: Int): Task[User]
}

case class UserRepositoryLive(database: Database) extends UserRepository {
  def getUser(id: Int): Task[User] = 
    database.query(s"SELECT * FROM users WHERE id = $id").map(parseUser)
}

// This layer needs a Database to provide UserRepository
val userRepoLayer: ZLayer[Database, Nothing, UserRepository] =
  ZLayer.fromFunction(UserRepositoryLive.apply _)

ZLayer Constructors

// From a simple value
val layer1 = ZLayer.succeed(ConsoleLogger())

// From a function of dependencies
val layer2 = ZLayer.fromFunction((db: Database) => UserRepositoryLive(db))

// From an effect that creates the service
val layer3 = ZLayer.fromZIO {
  for {
    config <- loadConfig()
    db     <- connectDatabase(config)
  } yield db
}

// With resource management (acquire/release)
val layer4 = ZLayer.scoped {
  ZIO.acquireRelease(openConnection())(conn => closeConnection(conn))
}

Providing Dependencies

Using provide

val effect: ZIO[Logger, Nothing, Unit] = 
  ZIO.serviceWithZIO[Logger](_.info("Hello"))

val loggerLayer: ZLayer[Any, Nothing, Logger] = 
  ZLayer.succeed(ConsoleLogger())

// Provide the layer to the effect
val program: ZIO[Any, Nothing, Unit] = 
  effect.provide(loggerLayer)

// Now it can run!

Providing Multiple Dependencies

val effect: ZIO[Database & Logger, Throwable, User] = for {
  _    <- ZIO.serviceWithZIO[Logger](_.info("Fetching user"))
  user <- ZIO.serviceWithZIO[Database](_.findUser(123))
} yield user

val databaseLayer: ZLayer[Any, Nothing, Database] = ???
val loggerLayer: ZLayer[Any, Nothing, Logger] = ???

// Provide both layers
val program = effect.provide(
  databaseLayer,
  loggerLayer
)

Layer Composition

Layers compose with ++ (horizontal) and >>> (vertical):

// Horizontal: combine independent layers
val combined = databaseLayer ++ loggerLayer

// Vertical: chain dependent layers
val userRepoLayer = databaseLayer >>> 
  ZLayer.fromFunction((db: Database) => UserRepositoryLive(db))

Complete Example: Multi-Tier Application

import zio._

// Domain model
case class User(id: Int, name: String, email: String)

// Services
trait Database {
  def query(sql: String): Task[List[User]]
}

trait Logger {
  def info(message: String): UIO[Unit]
  def error(message: String): UIO[Unit]
}

trait UserService {
  def getUser(id: Int): Task[Option[User]]
  def listUsers(): Task[List[User]]
}

// Implementations
case class InMemoryDatabase() extends Database {
  private val users = Map(
    1 -> User(1, "Alice", "alice@example.com"),
    2 -> User(2, "Bob", "bob@example.com")
  )

  def query(sql: String): Task[List[User]] = 
    ZIO.succeed(users.values.toList)
}

case class ConsoleLogger() extends Logger {
  def info(message: String): UIO[Unit] = 
    Console.printLine(s"[INFO] $message").orDie

  def error(message: String): UIO[Unit] = 
    Console.printLine(s"[ERROR] $message").orDie
}

case class UserServiceLive(database: Database, logger: Logger) extends UserService {
  def getUser(id: Int): Task[Option[User]] = for {
    _     <- logger.info(s"Fetching user $id")
    users <- database.query(s"SELECT * FROM users WHERE id = $id")
    user  = users.headOption
    _     <- user match {
              case Some(u) => logger.info(s"Found user: ${u.name}")
              case None    => logger.error(s"User $id not found")
            }
  } yield user

  def listUsers(): Task[List[User]] = for {
    _     <- logger.info("Fetching all users")
    users <- database.query("SELECT * FROM users")
    _     <- logger.info(s"Found ${users.length} users")
  } yield users
}

// Layers
object Layers {
  val database: ZLayer[Any, Nothing, Database] = 
    ZLayer.succeed(InMemoryDatabase())

  val logger: ZLayer[Any, Nothing, Logger] = 
    ZLayer.succeed(ConsoleLogger())

  val userService: ZLayer[Database & Logger, Nothing, UserService] =
    ZLayer.fromFunction(UserServiceLive.apply _)

  // Combine all layers
  val live: ZLayer[Any, Nothing, UserService] =
    database ++ logger >>> userService
}

// Companion object for convenience
object UserService {
  def getUser(id: Int): ZIO[UserService, Throwable, Option[User]] =
    ZIO.serviceWithZIO[UserService](_.getUser(id))

  def listUsers(): ZIO[UserService, Throwable, List[User]] =
    ZIO.serviceWithZIO[UserService](_.listUsers())
}

// Application
object UserApp extends ZIOAppDefault {

  val program = for {
    _     <- Console.printLine("=== User Management System ===")
    users <- UserService.listUsers()
    _     <- ZIO.foreach(users) { user =>
              Console.printLine(s"${user.id}: ${user.name} (${user.email})")
            }
    _     <- Console.printLine("\nFetching user 1:")
    user  <- UserService.getUser(1)
    _     <- user match {
              case Some(u) => Console.printLine(s"Found: ${u.name}")
              case None    => Console.printLine("Not found")
            }
  } yield ()

  def run = program.provide(Layers.live)
}

See how clean this is? No manual wiring, no constructors, just pure composition.

Testing with Layers

The power of ZLayer shines in testing:

// Test implementation
case class TestLogger() extends Logger {
  private val messages = scala.collection.mutable.ListBuffer[String]()

  def info(message: String): UIO[Unit] = 
    ZIO.succeed(messages += s"INFO: $message")

  def error(message: String): UIO[Unit] = 
    ZIO.succeed(messages += s"ERROR: $message")

  def getMessages: List[String] = messages.toList
}

case class TestDatabase() extends Database {
  def query(sql: String): Task[List[User]] = 
    ZIO.succeed(List(User(1, "Test User", "test@test.com")))
}

// Test layers
val testDatabase: ZLayer[Any, Nothing, Database] = 
  ZLayer.succeed(TestDatabase())

val testLogger: ZLayer[Any, Nothing, Logger] = 
  ZLayer.succeed(TestLogger())

val testUserService: ZLayer[Any, Nothing, UserService] =
  testDatabase ++ testLogger >>> Layers.userService

// Test
val test = (for {
  user <- UserService.getUser(1)
  _    <- ZIO.succeed(assert(user.isDefined))
} yield ()).provide(testUserService)

No mocking framework needed! Just provide different layer implementations.

Advanced Layer Patterns

Sharing Layers

// Create a layer once, use it multiple times
val sharedDatabase: ZLayer[Any, Nothing, Database] = 
  ZLayer.scoped {
    ZIO.acquireRelease(
      acquire = Database.connect()
    )(
      release = db => db.close().orDie
    )
  }.memoize

Conditional Layers

def makeLayer(useInMemory: Boolean): ZLayer[Any, Nothing, Database] =
  if (useInMemory)
    ZLayer.succeed(InMemoryDatabase())
  else
    ZLayer.fromZIO(PostgresDatabase.connect())

Layer Debugging

val debugLayer = databaseLayer.tap { db =>
  Console.printLine("Database layer created").orDie
}

Common Patterns and Best Practices

Service Pattern

Always use this pattern:

// 1. Define trait
trait ServiceName {
  def method1: ZIO[Any, Error, Result]
}

// 2. Implement
case class ServiceNameLive(deps: Deps) extends ServiceName {
  def method1: ZIO[Any, Error, Result] = ???
}

// 3. Companion object with accessors
object ServiceName {
  def method1: ZIO[ServiceName, Error, Result] =
    ZIO.serviceWithZIO[ServiceName](_.method1)
}

// 4. Layer
val layer: ZLayer[Deps, Nothing, ServiceName] =
  ZLayer.fromFunction(ServiceNameLive.apply _)

Organizing Layers

object AppLayers {
  // Infrastructure
  val database: ZLayer[Any, Throwable, Database] = ???
  val cache: ZLayer[Any, Throwable, Cache] = ???
  val logger: ZLayer[Any, Nothing, Logger] = ???

  // Services
  val userService: ZLayer[Database & Logger, Nothing, UserService] = ???
  val authService: ZLayer[Database & Cache, Nothing, AuthService] = ???

  // Complete app layer
  val live: ZLayer[Any, Throwable, UserService & AuthService] =
    database ++ cache ++ logger >>> 
    userService ++ authService
}

Error Handling in Layers

val dbLayer: ZLayer[Any, DbError, Database] = 
  ZLayer.fromZIO {
    Database.connect().mapError(e => DbError.ConnectionFailed(e))
  }

Real-World Example: REST API

// Services
trait UserRepository {
  def findById(id: Int): Task[Option[User]]
  def create(user: User): Task[User]
}

trait AuthService {
  def authenticate(token: String): Task[Option[UserId]]
}

trait UserController {
  def getUser(id: Int, token: String): Task[Response]
  def createUser(request: CreateUserRequest): Task[Response]
}

// Implementation
case class UserControllerLive(
  userRepo: UserRepository,
  authService: AuthService
) extends UserController {

  def getUser(id: Int, token: String): Task[Response] = for {
    userId <- authService.authenticate(token).someOrFail(Unauthorized)
    user   <- userRepo.findById(id).someOrFail(NotFound)
    _      <- ZIO.fail(Forbidden).when(user.id != userId)
  } yield Response.ok(user)

  def createUser(request: CreateUserRequest): Task[Response] = for {
    user <- userRepo.create(request.toUser)
  } yield Response.created(user)
}

// Layers
object ApiLayers {
  val userRepo: ZLayer[Database, Nothing, UserRepository] = ???
  val authService: ZLayer[Database & Cache, Nothing, AuthService] = ???

  val userController: ZLayer[UserRepository & AuthService, Nothing, UserController] =
    ZLayer.fromFunction(UserControllerLive.apply _)

  val live: ZLayer[Database & Cache, Nothing, UserController] =
    userRepo ++ authService >>> userController
}

Comparing with Other DI Approaches

Spring Framework (Java/Scala)

@Component
class UserService @Autowired()(database: Database, logger: Logger)
  • Requires annotations
  • Runtime wiring
  • Hard to test
  • Framework lock-in

Manual Constructor Injection

new UserService(
  new Database(config),
  new Logger()
)
  • Verbose
  • Order-dependent
  • Hard to manage in large apps

ZIO Approach

UserService.getUser(123).provide(Layers.live)
  • Type-safe
  • Compile-time checking
  • Easy testing (just swap layers)
  • No framework needed

Which approach gives you more confidence that your app is wired correctly?

Key Takeaways

  • Environment (R) tracks dependencies in the type system
  • ZIO.service accesses services from the environment
  • ZLayer is a recipe for constructing services
  • provide injects dependencies into effects
  • Layers compose with ++ and >>>
  • Testing is trivial—just provide different layers
  • No framework needed—it's built into ZIO

Common Questions

Q: When should I use ZLayer vs just passing parameters?

Use ZLayer when:

  • Multiple services depend on the same resource
  • You want different implementations for testing
  • The dependency graph is complex

Use parameters for:

  • Simple, local dependencies
  • Configuration values
  • Temporary, short-lived objects

Q: How do I handle circular dependencies?

Avoid circular dependencies by:

  • Proper layer separation
  • Using event-driven patterns
  • Breaking circular logic into smaller services

Q: Can I use ZLayer with existing code?

Yes! Wrap existing services:

val legacyLayer = ZLayer.fromZIO {
  ZIO.attempt(new LegacyService())
}

What's Next?

You now master dependency injection with ZIO! In Lesson 4: Concurrency with Fibers, you'll learn:

  • ZIO's lightweight fiber-based concurrency model
  • Running effects in parallel
  • Racing and timeouts
  • Handling interruption gracefully

Ready to unleash the power of concurrent programming? Let's continue!

Additional Resources